From 3d563d44ae542da9438fa69e711332af7804dbe6 Mon Sep 17 00:00:00 2001 From: Nick Zavaritsky Date: Tue, 9 Jul 2024 09:53:44 +0000 Subject: [PATCH] bpf2go: enable ebpf code reuse across go packages Extract imports from "bpf2go.hfiles.go" in the output dir, scan packages for header files, and expose headers to clang. C code consumes headers by providing a go package path in include directive, e.g. bpf2go.hfiles.go: package awesome import ( _ "example.org/foo" ) frob.c: #include "example.org/foo/foo.h" It is handy for sharing code between multiple ebpf blobs withing a project. Even better, it enables sharing ebpf code between multiple projects using go modules as delivery vehicle. By listing build dependencies in a .go file, we ensure that they are properly reflected in go.mod. Signed-off-by: Nick Zavaritsky --- cmd/bpf2go/README.md | 14 ++++++ cmd/bpf2go/main.go | 81 ++++++++++++++++++++++++++++++ cmd/bpf2go/main_test.go | 87 ++++++++++++++++++++++++++------ cmd/bpf2go/vfs.go | 107 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 cmd/bpf2go/vfs.go diff --git a/cmd/bpf2go/README.md b/cmd/bpf2go/README.md index bbc58e550..39ba801a8 100644 --- a/cmd/bpf2go/README.md +++ b/cmd/bpf2go/README.md @@ -34,6 +34,20 @@ up-to-date list. disable this behaviour using `-no-global-types`. You can add to the set of types by specifying `-type foo` for each type you'd like to generate. +## eBPF packages + +The tool can pull header files from other Go packages. To enable, create +`bpf2go.hfiles.go` in the output dir. Add packages you wish to pull headers +from as imports, e.g.: + +``` +// bpf2go.hfiles.go +package awesome +import _ "example.org/foo" +``` + +Write `#include "example.org/foo/foo.h"` to include `foo.h` from `example.org/foo`. + ## Examples See [examples/kprobe](../../examples/kprobe/main.go) for a fully worked out example. diff --git a/cmd/bpf2go/main.go b/cmd/bpf2go/main.go index fb077e139..70b823332 100644 --- a/cmd/bpf2go/main.go +++ b/cmd/bpf2go/main.go @@ -4,6 +4,9 @@ import ( "errors" "flag" "fmt" + "go/build" + "go/parser" + "go/token" "io" "os" "os/exec" @@ -11,6 +14,7 @@ import ( "regexp" "slices" "sort" + "strconv" "strings" "github.com/cilium/ebpf" @@ -273,6 +277,10 @@ func (b2g *bpf2go) convertAll() (err error) { } } + if err := b2g.addHeaders(); err != nil { + return fmt.Errorf("adding headers: %w", err) + } + for target, arches := range b2g.targetArches { if err := b2g.convert(target, arches); err != nil { return err @@ -282,6 +290,79 @@ func (b2g *bpf2go) convertAll() (err error) { return nil } +// addHeaders exposes header files from packages listed in +// $OUTPUT_DIR/bpf2go.hfiles.go to clang. C consumes them by giving a +// golang package path in include, e.g. +// #include "github.com/cilium/ebpf/foo/bar.h". +func (b2g *bpf2go) addHeaders() error { + f, err := os.Open(filepath.Join(b2g.outputDir, "bpf2go.hfiles.go")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(b2g.stdout, "Processing packages listed in %s\n", f.Name()) + + fset := token.NewFileSet() + ast, err := parser.ParseFile(fset, f.Name(), f, parser.ImportsOnly) + if err != nil { + return err + } + + var pkgs []*build.Package + buildCtx := build.Default + buildCtx.Dir = b2g.outputDir + for _, s := range ast.Imports { + path, _ := strconv.Unquote(s.Path.Value) + if build.IsLocalImport(path) { + return fmt.Errorf("local imports are not supported: %s", path) + } + pkg, err := buildCtx.Import(path, b2g.outputDir, 0) + if err != nil { + return err + } + if pkg.Dir == "" { + return fmt.Errorf("%s is missing locally: consider 'go mod download'", path) + } + if len(hfiles(pkg)) == 0 { + fmt.Fprintf(b2g.stdout, "Package doesn't contain .h files: %s\n", path) + continue + } + pkgs = append(pkgs, pkg) + } + + if len(pkgs) == 0 { + return nil + } + + vfs, err := createVfs(pkgs) + if err != nil { + return err + } + + path, err := persistVfs(vfs) + if err != nil { + return err + } + + b2g.cFlags = append([]string{"-ivfsoverlay", path, "-iquote", vfsRootDir}, b2g.cFlags...) + return nil +} + +// hfiles lists .h files in a package +func hfiles(pkg *build.Package) []string { + var res []string + for _, h := range pkg.HFiles { // includes .hpp, etc + if strings.HasSuffix(h, ".h") { + res = append(res, h) + } + } + return res +} + func (b2g *bpf2go) convert(tgt gen.Target, goarches gen.GoArches) (err error) { removeOnError := func(f *os.File) { if err != nil { diff --git a/cmd/bpf2go/main_test.go b/cmd/bpf2go/main_test.go index 5cf5cf027..21d0ea712 100644 --- a/cmd/bpf2go/main_test.go +++ b/cmd/bpf2go/main_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" "github.com/go-quicktest/qt" @@ -32,24 +33,12 @@ func TestRun(t *testing.T) { } modDir := t.TempDir() - execInModule := func(name string, args ...string) { - t.Helper() - - cmd := exec.Command(name, args...) - cmd.Dir = modDir - if out, err := cmd.CombinedOutput(); err != nil { - if out := string(out); out != "" { - t.Log(out) - } - t.Fatalf("Can't execute %s: %v", name, args) - } - } module := internal.CurrentModule - execInModule("go", "mod", "init", "bpf2go-test") + execInDir(t, modDir, "go", "mod", "init", "bpf2go-test") - execInModule("go", "mod", "edit", + execInDir(t, modDir, "go", "mod", "edit", // Require the module. The version doesn't matter due to the replace // below. fmt.Sprintf("-require=%s@v0.0.0", module), @@ -106,6 +95,76 @@ func main() { } } +func execInDir(t *testing.T, dir, name string, args ...string) { + t.Helper() + + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + if out := string(out); out != "" { + t.Log(out) + } + t.Fatalf("Can't execute %s: %v", name, args) + } +} + +func TestImports(t *testing.T) { + dir := t.TempDir() + + f := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} } + err := os.CopyFS(dir, fstest.MapFS{ + "foo/foo.go": f("package foo"), + "foo/foo.h": f("#define EXAMPLE_ORG__FOO__FOO_H 1"), + "bar/nested/nested.go": f("package nested"), + "bar/nested/nested.h": f("#define EXAMPLE_ORG__BAR__NESTED__NESTED_H 1"), + "bar/bpf2go.hfiles.go": f(` +package bar +import ( + _ "example.org/bar/nested" + _ "example.org/foo" +)`), + "bar/bar.c": f(` +//go:build ignore + +// include from current module, package listed in bpf2go.hfiles.go +#include "example.org/bar/nested/nested.h" +#ifndef EXAMPLE_ORG__BAR__NESTED__NESTED_H +#error "example.org/bar/nested/nested.h: unexpected file contents" +#endif + +// include from external module, package listed in bpf2go.hfiles.go +#include "example.org/foo/foo.h" +#ifndef EXAMPLE_ORG__FOO__FOO_H +#error "example.org/foo/foo.h: unexpected file contents" +#endif`)}) + if err != nil { + t.Fatal("extracting assets", err) + } + + fooModDir := filepath.Join(dir, "foo") + execInDir(t, fooModDir, "go", "mod", "init", "example.org/foo") + + barModDir := filepath.Join(dir, "bar") + execInDir(t, barModDir, "go", "mod", "init", "example.org/bar") + execInDir(t, barModDir, "go", "mod", "edit", "-require=example.org/foo@v0.0.0") + + execInDir(t, dir, "go", "work", "init") + execInDir(t, dir, "go", "work", "use", fooModDir) + execInDir(t, dir, "go", "work", "use", barModDir) + + err = run(io.Discard, []string{ + "-go-package", "bar", + "-output-dir", barModDir, + "-cc", testutils.ClangBin(t), + "bar", + filepath.Join(barModDir, "bar.c"), + }) + + if err != nil { + t.Fatal("Can't run:", err) + } +} + func TestHelp(t *testing.T) { var stdout bytes.Buffer err := run(&stdout, []string{"-help"}) diff --git a/cmd/bpf2go/vfs.go b/cmd/bpf2go/vfs.go new file mode 100644 index 000000000..7b83afbde --- /dev/null +++ b/cmd/bpf2go/vfs.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "fmt" + "go/build" + "os" + "path/filepath" + "slices" + "strings" +) + +// vfs is LLVM virtual file system parsed from a file +// +// In a nutshell, it is a tree of "directory" nodes with leafs being +// either "file" (a reference to file) or "directory-remap" (a reference +// to directory). +// +// https://github.com/llvm/llvm-project/blob/llvmorg-18.1.0/llvm/include/llvm/Support/VirtualFileSystem.h#L637 +type vfs struct { + Version int `json:"version"` + CaseSensitive bool `json:"case-sensitive"` + Roots []vfsItem `json:"roots"` +} + +type vfsItem struct { + Name string `json:"name"` + Type vfsItemType `json:"type"` + Contents []vfsItem `json:"contents,omitempty"` + ExternalContents string `json:"external-contents,omitempty"` +} + +type vfsItemType string + +const ( + vfsFile vfsItemType = "file" + vfsDirectory vfsItemType = "directory" +) + +func (vi *vfsItem) addDir(path string) (*vfsItem, error) { + for _, name := range strings.Split(path, "/") { + idx := vi.index(name) + if idx == -1 { + idx = len(vi.Contents) + vi.Contents = append(vi.Contents, vfsItem{Name: name, Type: vfsDirectory}) + } + vi = &vi.Contents[idx] + if vi.Type != vfsDirectory { + return nil, fmt.Errorf("adding %q: non-directory object already exists", path) + } + } + return vi, nil +} + +func (vi *vfsItem) index(name string) int { + return slices.IndexFunc(vi.Contents, func(item vfsItem) bool { + return item.Name == name + }) +} + +func persistVfs(vfs *vfs) (_ string, retErr error) { + temp, err := os.CreateTemp("", "") + if err != nil { + return "", err + } + defer func() { + temp.Close() + if retErr != nil { + os.Remove(temp.Name()) + } + }() + + if err = json.NewEncoder(temp).Encode(vfs); err != nil { + return "", err + } + + return temp.Name(), nil +} + +// vfsRootDir is the (virtual) directory where we mount go module sources +// for the C includes to pick them, e.g. "/github.com/cilium/ebpf". +const vfsRootDir = "/.vfsoverlay.d" + +// createVfs produces a vfs from a list of packages. It creates a +// (virtual) directory tree reflecting package import paths and adds +// links to header files. E.g. for github.com/foo/bar containing awesome.h: +// +// github.com/ +// foo/ +// bar/ +// awesome.h -> $HOME/go/pkg/mod/github.com/foo/bar@version/awesome.h +func createVfs(pkgs []*build.Package) (*vfs, error) { + roots := [1]vfsItem{{Name: vfsRootDir, Type: vfsDirectory}} + for _, pkg := range pkgs { + var headers []vfsItem + for _, h := range hfiles(pkg) { + headers = append(headers, vfsItem{Name: h, Type: vfsFile, + ExternalContents: filepath.Join(pkg.Dir, h)}) + } + dir, err := roots[0].addDir(pkg.ImportPath) + if err != nil { + return nil, err + } + dir.Contents = headers // NB don't append inplace, same package could be imported twice + } + return &vfs{CaseSensitive: true, Roots: roots[:]}, nil +}