diff --git a/.github/workflows/arm64-build-smoke.yml b/.github/workflows/arm64-build-smoke.yml index f915a81..7e79c64 100644 --- a/.github/workflows/arm64-build-smoke.yml +++ b/.github/workflows/arm64-build-smoke.yml @@ -41,6 +41,24 @@ jobs: - name: Build C validator dynamically run: make validator-dynamic + - name: Build + stage static validator for arm64 (hostload embed) + # uname -m is aarch64 here, so LIB_EMBED_ARCH resolves to arm64 and the + # binary lands at pkg/bpfcompat/validators/bpfcompat-validator-arm64. + run: make pkg-embed-validator + + - name: Build embeddable library (hostload, arm64) + run: make lib-hostload + + - name: Run hostload library tests (arm64) + run: go test -tags hostload ./pkg/bpfcompat/... + + - name: Upload arm64 validator (for multi-arch embed / release) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: bpfcompat-validator-arm64 + path: pkg/bpfcompat/validators/bpfcompat-validator-arm64 + if-no-files-found: error + - name: Build ARM64 BPF examples run: make examples-arm64 diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8080c..18dc50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once a ## [Unreleased] ### Added +- Embeddable library mode (`pkg/bpfcompat`). `ValidateBeforeLoad` / + `ValidateBytes` do a real load of a compiled eBPF object against the local + running kernel — no VM, no network — for use as a pre-load gate (e.g. + bpfman); `Validate` exposes the full VM matrix engine. Host-kernel loading is + gated behind the `hostload` build tag (default builds return + `ErrHostLoadNotEnabled`), the static validator is embedded via `go:embed` + (amd64/arm64; staged by `make pkg-embed-validator`, built by + `make lib-hostload`), and an internal provider seam keeps the public API + stable for a future in-process CGO validator. Pre-1.0 / experimental; see + `pkg/bpfcompat/README.md`. - Auto-type programs libbpf can't classify by section name: when a program is left `BPF_PROG_TYPE_UNSPEC` after open because its ELF section name isn't one libbpf recognizes, the validator sets the type the artifact's own loader diff --git a/Makefile b/Makefile index 0d4ca52..d22b58e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ LDFLAGS ?= -X $(VERSION_PKG).Version=$(VERSION) \ -X $(VERSION_PKG).BuildDate=$(BUILD_DATE) GO_BUILD_FLAGS ?= -trimpath -ldflags '$(LDFLAGS)' -.PHONY: all deps vendor doctor doctor-virtme doctor-firecracker doctor-arm64-kvm firecracker-install firecracker-kernel-install firecracker-runnable firecracker-preflight arm64-kvm-preflight build test test-vendor tidy validator validator-dynamic validator-static examples examples-arm64 oss-examples oss-evidence compatibility-site clean vm-ubuntu-22 vm-ubuntu-22-arm64 vm-images vm-images-tier1 vm-images-extended vm-images-expanded-2026 vm-images-expanded-2026-dry-run vm-images-latest-kernel matrix-runnable matrix-runnable-strict matrix-runnable-keep-manual latest-kernel-runnable upstream-kernel-runnable manual-image-check manual-image-check-strict profile-catalog-audit matrix-readiness runtime-selector-proof runtime-delivery-proof production-runtime-drill beta-tech-check tech-stability production-tech-check acceptance-dev-one acceptance-functional-dev-one acceptance-suite-dev-one acceptance-arm64-smoke acceptance-latest-kernel acceptance-upstream-kernel acceptance-firecracker-dev-one acceptance acceptance-expanded-runnable acceptance-evidence serve azure-provision-vm azure-bootstrap-vm azure-provision-foundation azure-production-boundary-proof azure-configure-tls azure-rotate-registry-secret +.PHONY: all deps vendor doctor doctor-virtme doctor-firecracker doctor-arm64-kvm firecracker-install firecracker-kernel-install firecracker-runnable firecracker-preflight arm64-kvm-preflight build test test-vendor tidy validator validator-dynamic validator-static pkg-embed-validator lib-hostload examples examples-arm64 oss-examples oss-evidence compatibility-site clean vm-ubuntu-22 vm-ubuntu-22-arm64 vm-images vm-images-tier1 vm-images-extended vm-images-expanded-2026 vm-images-expanded-2026-dry-run vm-images-latest-kernel matrix-runnable matrix-runnable-strict matrix-runnable-keep-manual latest-kernel-runnable upstream-kernel-runnable manual-image-check manual-image-check-strict profile-catalog-audit matrix-readiness runtime-selector-proof runtime-delivery-proof production-runtime-drill beta-tech-check tech-stability production-tech-check acceptance-dev-one acceptance-functional-dev-one acceptance-suite-dev-one acceptance-arm64-smoke acceptance-latest-kernel acceptance-upstream-kernel acceptance-firecracker-dev-one acceptance acceptance-expanded-runnable acceptance-evidence serve azure-provision-vm azure-bootstrap-vm azure-provision-foundation azure-production-boundary-proof azure-configure-tls azure-rotate-registry-secret all: build validator @@ -110,6 +110,24 @@ validator-dynamic: validator-static: $(MAKE) -C validator/c-libbpf clean all LIBBPF_LINK_MODE=static +# pkg-embed-validator builds the static validator and stages it under +# pkg/bpfcompat/validators/ so the embeddable library (`-tags hostload`) can +# go:embed it. Defaults to the host arch; cross-stage with e.g. +# `make pkg-embed-validator CC=aarch64-linux-gnu-gcc LIB_EMBED_ARCH=arm64`. +LIB_EMBED_ARCH ?= $(shell uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/') +LIB_VALIDATOR_DIR := pkg/bpfcompat/validators + +pkg-embed-validator: validator-static + @mkdir -p $(LIB_VALIDATOR_DIR) + cp validator/c-libbpf/bin/bpfcompat-validator \ + $(LIB_VALIDATOR_DIR)/bpfcompat-validator-$(LIB_EMBED_ARCH) + @echo "staged static validator for $(LIB_EMBED_ARCH) -> $(LIB_VALIDATOR_DIR)/bpfcompat-validator-$(LIB_EMBED_ARCH)" + +# lib-hostload builds the embeddable library with host-kernel loading enabled, +# staging the embedded validator first. +lib-hostload: pkg-embed-validator + $(GO) build -tags hostload ./pkg/bpfcompat/... + BPF_TARGET_ARCH ?= x86 BPF_ARCH_INCLUDE ?= /usr/include/x86_64-linux-gnu diff --git a/examples/libmode/main.go b/examples/libmode/main.go new file mode 100644 index 0000000..a00f165 --- /dev/null +++ b/examples/libmode/main.go @@ -0,0 +1,54 @@ +// Command libmode is a tiny driver that exercises the pkg/bpfcompat library +// entry point (ValidateBeforeLoad) end-to-end. Build with -tags hostload and +// run with privilege: +// +// go build -tags hostload -o /tmp/libmode ./examples/libmode +// sudo /tmp/libmode +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/kernel-guard/bpfcompat/pkg/bpfcompat" +) + +func main() { + // os.Exit only in main, with no pending defers (gocritic exitAfterDefer). + os.Exit(run()) +} + +func run() int { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: libmode ") + return 2 + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + start := time.Now() + res, err := bpfcompat.ValidateBeforeLoad(ctx, os.Args[1]) + elapsed := time.Since(start) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + + fmt.Printf("ValidateBeforeLoad -> OK=%v (%.0fms)\n", res.OK(), float64(elapsed.Microseconds())/1000) + fmt.Printf(" kernel : %s %s btf=%v\n", res.Kernel.Release, res.Kernel.Arch, res.Kernel.BTF) + fmt.Printf(" load : %s errno=%d\n", res.Load.Status, res.Load.Errno) + if !res.OK() { + fmt.Printf(" reason : [%s/%s] %s\n", res.Classification.Code, res.Classification.Confidence, res.Classification.Reason) + if res.Load.LogTail != "" { + fmt.Printf(" verifier-log-tail: %s\n", res.Load.LogTail) + } + } + + // Exit code mirrors what a gate would do: 0 loadable, 1 not. + if !res.OK() { + return 1 + } + return 0 +} diff --git a/pkg/bpfcompat/README.md b/pkg/bpfcompat/README.md new file mode 100644 index 0000000..d05830f --- /dev/null +++ b/pkg/bpfcompat/README.md @@ -0,0 +1,92 @@ +# pkg/bpfcompat + +Embeddable Go API for validating compiled eBPF objects against real Linux +kernels. + +There are two entry points, sharing one result type: + +| Function | What it does | Boots a VM? | Network? | +|---|---|---|---| +| `ValidateBeforeLoad` / `ValidateBytes` | Real load of an object against the **local running kernel** | No | No | +| `Validate` | Full **matrix** run across N kernel profiles in disposable VMs | Yes | Only for OCI artifacts | + +`ValidateBeforeLoad` is the pre-load gate: the node it runs on is the node the +program will load on, so the running kernel *is* the target. It does a real +`bpf()` load (the verifier), not static ELF/BTF inference — strictly more +accurate, and fast enough to sit in front of every load. + +## Quick start (pre-load gate) + +```go +import "github.com/kernel-guard/bpfcompat/pkg/bpfcompat" + +res, err := bpfcompat.ValidateBeforeLoad(ctx, "probe.bpf.o") +if err != nil { + return err +} +if !res.OK() { + return fmt.Errorf("won't load on %s: [%s] %s", + res.Kernel.Release, res.Classification.Code, res.Classification.Reason) +} +// safe to load +``` + +`ValidateBytes(ctx, obj []byte, ...)` is the same for an object already in +memory (e.g. one you pulled from an OCI store yourself). Nothing in this path +touches the network — suitable for air-gapped environments. + +## Build requirements for host-load + +Host-kernel loading is gated behind the **`hostload` build tag**. Binaries that +must never load BPF on their own kernel (e.g. a public-facing server) are built +*without* the tag, and `ValidateBeforeLoad` / `ValidateBytes` then return +`ErrHostLoadNotEnabled`. + +To enable it: + +```sh +make pkg-embed-validator # builds the static validator, stages it for embed +go build -tags hostload ./... # or: make lib-hostload +``` + +Runtime requirements: + +- **Privilege** — loading BPF needs `CAP_BPF`/`CAP_SYS_ADMIN`. The library does + not escalate; the caller must already hold it (bpfman does). +- **No external assets** — the static validator is embedded via `go:embed` and + extracted to a private temp dir per call. `amd64` and `arm64` are supported; + other arches return a clear error unless a validator is supplied via + `WithValidator`. + +## Matrix mode + +`Validate(ctx, Config)` drives the same engine as the `bpfcompat` CLI and GitHub +Action — one or more kernel profiles, each booted in a disposable VM. It does +**not** load on the host (use `ValidateBeforeLoad` for that). Returns a `Report` +with one `Result` per profile plus the raw on-disk report. + +## Options + +- `WithMode(LoadOnly | LoadAttach)` — `LoadOnly` (default) runs the verifier + only; `LoadAttach` also attaches to hooks (more invasive). +- `WithFeatureProbe(bool)` — off by default; enables the full kernel-capability + census (slower). +- `WithValidator(path)` — pin a validator binary instead of the embedded one. + +## Stability + +**Pre-1.0 / experimental.** While the module is `v0.x`, the surface of this +package may change between minor versions. Once it stabilises it will follow +[semantic versioning](https://semver.org/): no breaking changes to exported +identifiers within a major version. Pin a tag if you depend on it. + +The internal seam (`validatorProvider`) lets the embed-and-exec implementation +be replaced by an in-process CGO validator later **without** changing this +public API or the `Result` shape — that change would not be breaking. + +## Result + +`Result` carries `Loadable` (the gate), `Kernel` (release/arch/BTF), `Load` +(status/errno/verifier-log tail), `Attach`, `Capabilities`, a machine-readable +`Classification` (branch on `Code`, don't parse `Reason`), and `RawJSON` for the +full underlying document. `Result.OK()` == `Loadable`. diff --git a/pkg/bpfcompat/bpfcompat.go b/pkg/bpfcompat/bpfcompat.go new file mode 100644 index 0000000..08a5525 --- /dev/null +++ b/pkg/bpfcompat/bpfcompat.go @@ -0,0 +1,331 @@ +// Package bpfcompat is the embeddable API for validating compiled eBPF +// objects. +// +// The library mode — ValidateBeforeLoad / ValidateBytes — does a real load of a +// BPF object against the LOCAL running kernel, with no VM and no network. It is +// intended for a pre-load gate such as bpfman's: the node it runs on is the node +// the program will load on, so the running kernel IS the target. That is +// strictly more accurate than static ELF/BTF inference, and fast enough to sit +// in front of every load. +// +// Host-kernel loading is gated behind the "hostload" build tag. Binaries that +// must never load BPF on their own kernel (the public demo / server) are built +// WITHOUT the tag, and ValidateBeforeLoad then returns ErrHostLoadNotEnabled. +// +// import "github.com/kernel-guard/bpfcompat/pkg/bpfcompat" +// +// res, err := bpfcompat.ValidateBeforeLoad(ctx, "probe.bpf.o") +// if err != nil { return err } +// if !res.OK() { +// return fmt.Errorf("won't load on %s: %s", res.Kernel.Release, res.Classification.Reason) +// } +package bpfcompat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/kernel-guard/bpfcompat/internal/classifier" +) + +// ErrHostLoadNotEnabled is returned by ValidateBeforeLoad / ValidateBytes when +// the package was built without the "hostload" build tag. Build with +// `-tags hostload` to enable host-kernel loading. +var ErrHostLoadNotEnabled = errors.New("bpfcompat: host-kernel loading not enabled (build with -tags hostload)") + +// Mode selects how far validation goes. +type Mode int + +const ( + // LoadOnly runs the verifier only — no attach. Fastest and least invasive; + // the right choice for a pre-load gate. This is the default. + LoadOnly Mode = iota + // LoadAttach also attaches each program to its hook. More invasive and + // requires the hook to exist on the running kernel. + LoadAttach +) + +func (m Mode) attachFlag() string { + if m == LoadAttach { + return "best-effort" + } + return "disabled" +} + +// Result is the outcome of validating one object against one kernel. +type Result struct { + // Loadable is the gate: did the object pass the verifier? + Loadable bool + + // Profile is the matrix profile id this result is for. Empty for + // ValidateBeforeLoad (the target is simply the local kernel). + Profile string + + Kernel KernelInfo // what it was validated against + Load LoadResult // verifier status, errno, log tail + Attach AttachResult // populated only when Mode == LoadAttach + Capabilities Capabilities // live-probed kernel support + Classification Classification // stable reason when not Loadable + + // RawJSON is the validator's full result document, for callers that want + // everything (program_variants, map_fixups, auto_sized_maps, ...). + RawJSON []byte +} + +// OK reports whether the object is safe to load on this kernel. +func (r Result) OK() bool { return r.Loadable } + +// KernelInfo identifies the kernel the object was validated against. +type KernelInfo struct { + Release string // uname -r, e.g. "6.17.0-35-generic" + Arch string // uname -m + BTF bool // kernel BTF present (/sys/kernel/btf/vmlinux) +} + +// LoadResult is the verifier outcome. +type LoadResult struct { + Status string // "pass" | "fail" + Errno int // 0 on success, negative errno on reject + LogTail string // tail of the verifier message on failure +} + +// AttachResult is the attach outcome (only meaningful in LoadAttach mode). +type AttachResult struct { + Mode string + Status string + Attempted int + Passed int + Failed int +} + +// Capabilities is what the kernel actually supports, as probed at validate +// time. Values are the validator's probe verdicts ("supported", +// "unsupported", "inconclusive", "permission_denied", ...). +type Capabilities struct { + MapTypes map[string]string + ProgramTypes map[string]string + BTF bool +} + +// Classification is a stable, machine-readable reason for a verdict — branch on +// Code, don't parse Reason. Empty when the object loaded cleanly. +type Classification struct { + Code string + Confidence string + Reason string +} + +// config is built up by Options. +type config struct { + mode Mode + probeFeats bool + validatorBin string // override; empty => embedded (hostload build only) +} + +// Option configures a validation call. +type Option func(*config) + +// WithMode selects LoadOnly (default) or LoadAttach. +func WithMode(m Mode) Option { return func(c *config) { c.mode = m } } + +// WithFeatureProbe enables the full kernel-capability census (slower; uses +// bpftool when present). Off by default — a pre-load gate only needs the load +// verdict, not a capability map. +func WithFeatureProbe(on bool) Option { return func(c *config) { c.probeFeats = on } } + +// WithValidator pins the validator binary path instead of using the embedded +// one. Useful for tests and for callers shipping their own validator. +func WithValidator(path string) Option { return func(c *config) { c.validatorBin = path } } + +func newConfig(opts ...Option) config { + c := config{mode: LoadOnly} + for _, o := range opts { + o(&c) + } + return c +} + +// validatorProvider runs the validator against an artifact and returns its raw +// result document. It is the seam that decouples the public API from how +// validation is actually performed: the default execProvider runs the embedded +// static binary, and a future in-process CGO implementation can be dropped in +// behind this interface without changing Result or any exported function. +type validatorProvider interface { + run(ctx context.Context, artifactPath string, c config) ([]byte, error) +} + +// validateWith runs a provider and maps its result document into a Result. This +// is the always-built core shared by every entry point. +func validateWith(ctx context.Context, prov validatorProvider, artifactPath string, c config) (Result, error) { + data, err := prov.run(ctx, artifactPath, c) + if err != nil { + return Result{}, err + } + return parseResult(data) +} + +// execProvider runs an external validator binary (the embedded static validator +// once extracted, or a path supplied via WithValidator). +type execProvider struct{ bin string } + +func (p execProvider) run(ctx context.Context, artifactPath string, c config) ([]byte, error) { + outFile, err := os.CreateTemp("", "bpfcompat-result-*.json") + if err != nil { + return nil, fmt.Errorf("create result file: %w", err) + } + outPath := outFile.Name() + _ = outFile.Close() + defer os.Remove(outPath) + + probe := "false" + if c.probeFeats { + probe = "true" + } + args := []string{ + "--artifact", artifactPath, + "--out", outPath, + "--attach-mode", c.mode.attachFlag(), + "--probe-features", probe, + } + cmd := exec.CommandContext(ctx, p.bin, args...) + // The validator writes its verdict to --out; a non-zero exit is expected on + // a failed load and is not an execution error. We only surface exec errors + // when no result document was produced. + combined, runErr := cmd.CombinedOutput() + + data, readErr := os.ReadFile(outPath) + if readErr != nil || len(data) == 0 { + if runErr != nil { + return nil, fmt.Errorf("validator did not produce a result: %w (output: %s)", runErr, truncate(string(combined), 400)) + } + return nil, fmt.Errorf("validator produced an empty result") + } + return data, nil +} + +// validatorDoc is the subset of the validator's result schema we map from. +type validatorDoc struct { + Status string `json:"status"` + Host struct { + Release string `json:"release"` + Machine string `json:"machine"` + } `json:"host"` + Load struct { + Status string `json:"status"` + ErrorCode int `json:"error_code"` + Error string `json:"error"` + } `json:"load"` + Attach struct { + Mode string `json:"mode"` + Status string `json:"status"` + Attempted int `json:"attempted"` + Passed int `json:"passed"` + Failed int `json:"failed"` + } `json:"attach"` + BTF struct { + KernelBTFAvailable bool `json:"kernel_btf_available"` + ArtifactHasBTF bool `json:"artifact_has_btf"` + ArtifactHasBTFExt bool `json:"artifact_has_btf_ext"` + } `json:"btf"` + Capabilities struct { + MapTypes map[string]probe `json:"map_types"` + ProgramTypes map[string]probe `json:"program_types"` + } `json:"capabilities"` + Logs struct { + Libbpf string `json:"libbpf"` + } `json:"logs"` + Discovery struct { + Programs []struct { + Section string `json:"section"` + } `json:"programs"` + } `json:"discovery"` +} + +type probe struct { + Status string `json:"status"` +} + +func parseResult(data []byte) (Result, error) { + var d validatorDoc + if err := json.Unmarshal(data, &d); err != nil { + return Result{}, fmt.Errorf("parse validator result: %w", err) + } + + res := Result{ + Loadable: d.Load.Status == "pass", + Kernel: KernelInfo{ + Release: d.Host.Release, + Arch: d.Host.Machine, + BTF: d.BTF.KernelBTFAvailable, + }, + Load: LoadResult{ + Status: d.Load.Status, + Errno: d.Load.ErrorCode, + LogTail: tailOf(d.Logs.Libbpf, d.Load.Error), + }, + Attach: AttachResult{ + Mode: d.Attach.Mode, + Status: d.Attach.Status, + Attempted: d.Attach.Attempted, + Passed: d.Attach.Passed, + Failed: d.Attach.Failed, + }, + Capabilities: Capabilities{ + MapTypes: flattenProbes(d.Capabilities.MapTypes), + ProgramTypes: flattenProbes(d.Capabilities.ProgramTypes), + BTF: d.BTF.KernelBTFAvailable, + }, + RawJSON: data, + } + + if !res.Loadable { + sections := make([]string, 0, len(d.Discovery.Programs)) + for _, p := range d.Discovery.Programs { + sections = append(sections, p.Section) + } + cl := classifier.Classify(classifier.Input{ + LoadStatus: d.Load.Status, + LoadErrorCode: d.Load.ErrorCode, + LoadError: d.Load.Error, + AttachStatus: d.Attach.Status, + AttachMode: d.Attach.Mode, + KernelBTFAvailable: d.BTF.KernelBTFAvailable, + ArtifactHasBTF: d.BTF.ArtifactHasBTF, + ArtifactHasBTFExt: d.BTF.ArtifactHasBTFExt, + LibbpfLog: d.Logs.Libbpf, + KernelRelease: d.Host.Release, + ProgramSections: sections, + }) + res.Classification = Classification{Code: cl.Code, Confidence: cl.Confidence, Reason: cl.Reason} + } + return res, nil +} + +func flattenProbes(m map[string]probe) map[string]string { + if len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v.Status + } + return out +} + +func tailOf(libbpfLog, loadErr string) string { + if libbpfLog != "" { + return truncate(libbpfLog, 4096) + } + return loadErr +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[len(s)-n:] +} diff --git a/pkg/bpfcompat/bpfcompat_test.go b/pkg/bpfcompat/bpfcompat_test.go new file mode 100644 index 0000000..bf50be6 --- /dev/null +++ b/pkg/bpfcompat/bpfcompat_test.go @@ -0,0 +1,124 @@ +package bpfcompat + +import ( + "testing" + + "github.com/kernel-guard/bpfcompat/pkg/schema" +) + +// parseResult maps the validator's own result document (host-load path). +func TestParseResult_LoadPass(t *testing.T) { + doc := []byte(`{ + "status":"pass", + "host":{"release":"6.17.0-35-generic","machine":"x86_64"}, + "load":{"status":"pass","error_code":0,"error":""}, + "attach":{"mode":"disabled","status":"skipped"}, + "btf":{"kernel_btf_available":true,"artifact_has_btf":true}, + "capabilities":{"map_types":{"ringbuf":{"status":"supported"}},"program_types":{"kprobe":{"status":"supported"}}} + }`) + r, err := parseResult(doc) + if err != nil { + t.Fatalf("parseResult: %v", err) + } + if !r.OK() { + t.Errorf("OK() = false, want true") + } + if r.Kernel.Release != "6.17.0-35-generic" || r.Kernel.Arch != "x86_64" || !r.Kernel.BTF { + t.Errorf("kernel = %+v", r.Kernel) + } + if got := r.Capabilities.MapTypes["ringbuf"]; got != "supported" { + t.Errorf("ringbuf cap = %q, want supported", got) + } + if r.Classification.Code != "" { + t.Errorf("clean load should have no classification, got %q", r.Classification.Code) + } +} + +func TestParseResult_LoadFail_Classified(t *testing.T) { + doc := []byte(`{ + "status":"fail", + "host":{"release":"5.4.0-200-generic","machine":"x86_64"}, + "load":{"status":"fail","error_code":-22,"error":"Invalid argument"}, + "btf":{"kernel_btf_available":true,"artifact_has_btf":true}, + "logs":{"libbpf":"libbpf: prog 'x': failed to resolve CO-RE relocation"}, + "discovery":{"programs":[{"section":"kprobe/x"}]} + }`) + r, err := parseResult(doc) + if err != nil { + t.Fatalf("parseResult: %v", err) + } + if r.OK() { + t.Errorf("OK() = true, want false") + } + if r.Load.Errno != -22 { + t.Errorf("errno = %d, want -22", r.Load.Errno) + } + if r.Classification.Code == "" { + t.Errorf("failed load should be classified, got empty code") + } +} + +// resultFromTarget maps the aggregated matrix schema (Validate path). +func TestResultFromTarget(t *testing.T) { + tgt := schema.Target{ + ProfileID: "ubuntu-24.04-6.8", + Status: "pass", + Host: &schema.TargetEnv{Kernel: "6.8.0-106-generic", Arch: "x86_64"}, + BTF: &schema.TargetBTF{KernelBTFAvailable: true}, + Validation: &schema.Validation{ + LoadStatus: "pass", AttachMode: "best-effort", AttachStatus: "pass", + AttachAttempted: 2, AttachPassed: 2, + }, + } + r := resultFromTarget(tgt) + if !r.OK() { + t.Errorf("OK() = false, want true") + } + if r.Profile != "ubuntu-24.04-6.8" { + t.Errorf("profile = %q", r.Profile) + } + if r.Kernel.Release != "6.8.0-106-generic" { + t.Errorf("kernel release = %q (should prefer booted host)", r.Kernel.Release) + } + if r.Attach.Passed != 2 { + t.Errorf("attach passed = %d, want 2", r.Attach.Passed) + } + if len(r.RawJSON) == 0 { + t.Errorf("RawJSON should be populated") + } +} + +func TestResultFromTarget_InfraFailFallback(t *testing.T) { + // No Validation block (infra failure before load) -> fall back to status. + tgt := schema.Target{ProfileID: "p", Status: "fail"} + r := resultFromTarget(tgt) + if r.OK() { + t.Errorf("OK() = true, want false on infra fail") + } + if r.Load.Status != "fail" { + t.Errorf("load status = %q, want fail", r.Load.Status) + } +} + +func TestReportFromSchema(t *testing.T) { + rep := schema.ReportV01{ + Summary: schema.SummaryInfo{Status: "fail", Notes: []string{"1 of 2 required targets failed"}}, + Targets: []schema.Target{ + {ProfileID: "a", Status: "pass", Validation: &schema.Validation{LoadStatus: "pass"}}, + {ProfileID: "b", Status: "fail", Validation: &schema.Validation{LoadStatus: "fail", LoadErrorCode: -22}}, + }, + } + out := reportFromSchema("/run/dir", rep) + if out.Summary.Status != "fail" || len(out.Summary.Notes) != 1 { + t.Errorf("summary = %+v", out.Summary) + } + if len(out.Results) != 2 { + t.Fatalf("results = %d, want 2", len(out.Results)) + } + if !out.Results[0].OK() || out.Results[1].OK() { + t.Errorf("loadable mismatch: %v %v", out.Results[0].OK(), out.Results[1].OK()) + } + if out.RunDir != "/run/dir" { + t.Errorf("rundir = %q", out.RunDir) + } +} diff --git a/pkg/bpfcompat/embed_amd64.go b/pkg/bpfcompat/embed_amd64.go new file mode 100644 index 0000000..ae9651f --- /dev/null +++ b/pkg/bpfcompat/embed_amd64.go @@ -0,0 +1,8 @@ +//go:build hostload && amd64 + +package bpfcompat + +import _ "embed" + +//go:embed validators/bpfcompat-validator-amd64 +var embeddedValidator []byte diff --git a/pkg/bpfcompat/embed_arm64.go b/pkg/bpfcompat/embed_arm64.go new file mode 100644 index 0000000..b57a1cd --- /dev/null +++ b/pkg/bpfcompat/embed_arm64.go @@ -0,0 +1,8 @@ +//go:build hostload && arm64 + +package bpfcompat + +import _ "embed" + +//go:embed validators/bpfcompat-validator-arm64 +var embeddedValidator []byte diff --git a/pkg/bpfcompat/embed_unsupported.go b/pkg/bpfcompat/embed_unsupported.go new file mode 100644 index 0000000..39e0adf --- /dev/null +++ b/pkg/bpfcompat/embed_unsupported.go @@ -0,0 +1,8 @@ +//go:build hostload && !amd64 && !arm64 + +package bpfcompat + +// No embedded validator is available for this GOARCH. resolveValidator returns +// a clear error when embeddedValidator is empty; callers can still supply one +// via WithValidator. +var embeddedValidator []byte diff --git a/pkg/bpfcompat/fenced_test.go b/pkg/bpfcompat/fenced_test.go new file mode 100644 index 0000000..aaa934f --- /dev/null +++ b/pkg/bpfcompat/fenced_test.go @@ -0,0 +1,18 @@ +//go:build !hostload + +package bpfcompat + +import ( + "context" + "testing" +) + +// The default build (no hostload tag) must keep host loading fenced off. +func TestHostLoadFencedOff(t *testing.T) { + if _, err := ValidateBeforeLoad(context.Background(), "x.bpf.o"); err != ErrHostLoadNotEnabled { + t.Errorf("ValidateBeforeLoad err = %v, want ErrHostLoadNotEnabled", err) + } + if _, err := ValidateBytes(context.Background(), []byte("x")); err != ErrHostLoadNotEnabled { + t.Errorf("ValidateBytes err = %v, want ErrHostLoadNotEnabled", err) + } +} diff --git a/pkg/bpfcompat/validate_hostload.go b/pkg/bpfcompat/validate_hostload.go new file mode 100644 index 0000000..0c5efaf --- /dev/null +++ b/pkg/bpfcompat/validate_hostload.go @@ -0,0 +1,69 @@ +//go:build hostload + +package bpfcompat + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" +) + +// embeddedValidator holds the static validator binary for the current GOARCH. +// It is defined by the per-arch embed_.go files (built by +// `make pkg-embed-validator`). Air-gap-safe: no external assets at runtime. + +// ValidateBeforeLoad does a real load of the BPF object at artifactPath against +// the local running kernel. No VM, no network. Requires CAP_BPF/CAP_SYS_ADMIN +// (the caller's privilege — the library does not escalate). +func ValidateBeforeLoad(ctx context.Context, artifactPath string, opts ...Option) (Result, error) { + c := newConfig(opts...) + bin, cleanup, err := resolveValidator(c) + if err != nil { + return Result{}, err + } + defer cleanup() + return validateWith(ctx, execProvider{bin: bin}, artifactPath, c) +} + +// ValidateBytes is ValidateBeforeLoad for an object already in memory (e.g. +// pulled from an OCI store by the caller). Nothing here touches the network. +func ValidateBytes(ctx context.Context, obj []byte, opts ...Option) (Result, error) { + tmp, err := os.CreateTemp("", "bpfcompat-artifact-*.bpf.o") + if err != nil { + return Result{}, fmt.Errorf("stage artifact bytes: %w", err) + } + path := tmp.Name() + defer os.Remove(path) + if _, err := tmp.Write(obj); err != nil { + _ = tmp.Close() + return Result{}, fmt.Errorf("write artifact bytes: %w", err) + } + if err := tmp.Close(); err != nil { + return Result{}, fmt.Errorf("flush artifact bytes: %w", err) + } + return ValidateBeforeLoad(ctx, path, opts...) +} + +// resolveValidator returns an executable validator path and a cleanup func. It +// honours WithValidator; otherwise it extracts the embedded per-arch binary to +// a private temp file. +func resolveValidator(c config) (string, func(), error) { + if c.validatorBin != "" { + return c.validatorBin, func() {}, nil + } + if len(embeddedValidator) == 0 { + return "", func() {}, fmt.Errorf("no embedded validator for GOARCH=%s; run `make pkg-embed-validator` or pass WithValidator", runtime.GOARCH) + } + dir, err := os.MkdirTemp("", "bpfcompat-validator-") + if err != nil { + return "", func() {}, fmt.Errorf("create validator dir: %w", err) + } + path := filepath.Join(dir, "bpfcompat-validator") + if err := os.WriteFile(path, embeddedValidator, 0o755); err != nil { + os.RemoveAll(dir) + return "", func() {}, fmt.Errorf("extract validator: %w", err) + } + return path, func() { os.RemoveAll(dir) }, nil +} diff --git a/pkg/bpfcompat/validate_matrix.go b/pkg/bpfcompat/validate_matrix.go new file mode 100644 index 0000000..9fb520e --- /dev/null +++ b/pkg/bpfcompat/validate_matrix.go @@ -0,0 +1,168 @@ +package bpfcompat + +import ( + "context" + "encoding/json" + "os" + "time" + + "github.com/kernel-guard/bpfcompat/internal/runner" + "github.com/kernel-guard/bpfcompat/pkg/schema" +) + +// Config drives the full matrix engine via Validate: one or more kernel +// profiles, each booted in a disposable VM. This is the same engine the CLI and +// GitHub Action use. Consumers on a pre-load path want ValidateBeforeLoad +// instead — Validate boots VMs and is not host-local. +type Config struct { + // Artifact is a local .bpf.o path or an OCI reference (OCI is fetched, so + // it is not air-gap-safe). + Artifact string + Mode Mode + + // Matrix is a path to a matrix YAML. If empty and Quick is set, the + // built-in quick set (old LTS → recent) is used. + Matrix string + Quick bool + + // Manifest optionally points at a manifest YAML (inner-map shapes, + // runtime-sized map auto-sizing, program-type overrides, functional tests). + Manifest string + + // Runner selects the execution backend: "" (=> "vm"), "vm", "virtme-ng", + // or "firecracker". The host runner is not reachable here by design; use + // ValidateBeforeLoad (hostload build) for host-local validation. + Runner string + + Concurrency int + Timeout time.Duration + + // WorkDir is the run workspace root; empty uses the engine default. + WorkDir string +} + +// Report is the result of a matrix run: one Result per profile plus the raw, +// on-disk report document. +type Report struct { + Summary Summary + Results []Result + RunDir string + Raw schema.ReportV01 +} + +// Summary is the overall verdict across all profiles. +type Summary struct { + Status string // "pass" | "fail" | ... + Notes []string +} + +func (c Config) validationMode() string { + if c.Mode == LoadAttach { + return runner.ValidationModeLoadAttach + } + return runner.ValidationModeLoadOnly +} + +// Validate runs the full matrix engine and returns the aggregated Report. A +// non-zero number of failed targets is reported in Report.Summary, not as a Go +// error — err is non-nil only when the run could not be executed. +func Validate(ctx context.Context, cfg Config) (Report, error) { + rcfg := runner.Config{ + ArtifactPath: cfg.Artifact, + ValidationMode: cfg.validationMode(), + MatrixPath: cfg.Matrix, + Quick: cfg.Quick, + ManifestPath: cfg.Manifest, + Runner: cfg.Runner, + Concurrency: cfg.Concurrency, + Timeout: cfg.Timeout, + WorkDir: cfg.WorkDir, + } + if rcfg.Timeout == 0 { + rcfg.Timeout = 10 * time.Minute + } + if rcfg.WorkDir == "" { + dir, err := os.MkdirTemp("", "bpfcompat-run-") + if err != nil { + return Report{}, err + } + rcfg.WorkDir = dir + } + // The engine writes a report.json to OutPath; the same document is returned + // in-memory as Report.Raw. Default to a temp file so callers need not pick a + // path. Full run artifacts live under Report.RunDir. + var tmpReport string + if rcfg.OutPath == "" { + f, err := os.CreateTemp("", "bpfcompat-report-*.json") + if err != nil { + return Report{}, err + } + tmpReport = f.Name() + _ = f.Close() + rcfg.OutPath = tmpReport + } + res, err := runner.ExecuteBootstrap(ctx, rcfg) + if tmpReport != "" { + os.Remove(tmpReport) + } + if err != nil { + return Report{}, err + } + return reportFromSchema(res.RunDir, res.Report), nil +} + +func reportFromSchema(runDir string, rep schema.ReportV01) Report { + out := Report{ + Summary: Summary{Status: rep.Summary.Status, Notes: rep.Summary.Notes}, + RunDir: runDir, + Raw: rep, + Results: make([]Result, 0, len(rep.Targets)), + } + for _, t := range rep.Targets { + out.Results = append(out.Results, resultFromTarget(t)) + } + return out +} + +func resultFromTarget(t schema.Target) Result { + r := Result{ + Profile: t.ProfileID, + Kernel: KernelInfo{ + BTF: t.BTF != nil && t.BTF.KernelBTFAvailable, + }, + Classification: Classification{ + Code: t.ClassificationCode, + Confidence: t.ClassificationConfidence, + Reason: t.ClassificationReason, + }, + } + + // Prefer the actually-booted host environment over the declared profile. + if env := t.Host; env != nil { + r.Kernel.Release, r.Kernel.Arch = env.Kernel, env.Arch + } else if env := t.Profile; env != nil { + r.Kernel.Release, r.Kernel.Arch = env.Kernel, env.Arch + } + + if v := t.Validation; v != nil { + r.Loadable = v.LoadStatus == "pass" + r.Load = LoadResult{Status: v.LoadStatus, Errno: v.LoadErrorCode, LogTail: v.LoadError} + r.Attach = AttachResult{ + Mode: v.AttachMode, + Status: v.AttachStatus, + Attempted: v.AttachAttempted, + Passed: v.AttachPassed, + Failed: v.AttachFailed, + } + } else { + // No validation block (e.g. infra failure before load) — fall back to + // the target's overall status. + r.Loadable = t.Status == "pass" + r.Load = LoadResult{Status: t.Status} + } + + if raw, err := json.Marshal(t); err == nil { + r.RawJSON = raw + } + return r +} diff --git a/pkg/bpfcompat/validate_stub.go b/pkg/bpfcompat/validate_stub.go new file mode 100644 index 0000000..6e85a07 --- /dev/null +++ b/pkg/bpfcompat/validate_stub.go @@ -0,0 +1,17 @@ +//go:build !hostload + +package bpfcompat + +import "context" + +// ValidateBeforeLoad is disabled in builds without the "hostload" tag and +// returns ErrHostLoadNotEnabled. See the package doc for the rationale. +func ValidateBeforeLoad(ctx context.Context, artifactPath string, opts ...Option) (Result, error) { + return Result{}, ErrHostLoadNotEnabled +} + +// ValidateBytes is disabled in builds without the "hostload" tag and returns +// ErrHostLoadNotEnabled. +func ValidateBytes(ctx context.Context, obj []byte, opts ...Option) (Result, error) { + return Result{}, ErrHostLoadNotEnabled +} diff --git a/pkg/bpfcompat/validators/.gitignore b/pkg/bpfcompat/validators/.gitignore new file mode 100644 index 0000000..e692f29 --- /dev/null +++ b/pkg/bpfcompat/validators/.gitignore @@ -0,0 +1,4 @@ +# Embedded static validator binaries are build artifacts, staged by +# `make pkg-embed-validator` before a `-tags hostload` build. Do not commit them. +bpfcompat-validator-* +!.gitignore