diff --git a/cmd/bpf2go/README.md b/cmd/bpf2go/README.md index 052efdddb..35b56f464 100644 --- a/cmd/bpf2go/README.md +++ b/cmd/bpf2go/README.md @@ -38,6 +38,13 @@ 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. +## Editor integration + +Pass `-compdb path/to/compile_commands.json` (or set `BPF2GO_COMPDB`) to upsert +[JSON Compilation Database](https://clang.llvm.org/docs/JSONCompilationDatabase.html) +entry for each source. The file is written before compilation, so the entry is +kept up to date even when the build step itself is unable to run. + ## Examples See [examples/kprobe](../../examples/kprobe/main.go) for a fully worked out example. diff --git a/cmd/bpf2go/compdb.go b/cmd/bpf2go/compdb.go new file mode 100644 index 000000000..aa0e3c80a --- /dev/null +++ b/cmd/bpf2go/compdb.go @@ -0,0 +1,82 @@ +//go:build !windows + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/sys/unix" +) + +// A single JSON Compilation Database entry. +// See https://clang.llvm.org/docs/JSONCompilationDatabase.html +type compdbEntry struct { + Directory string `json:"directory"` + File string `json:"file"` + Arguments []string `json:"arguments"` +} + +// writeCompDB upserts entry into the compdb at path. +// Concurrent writers are serialised through a sibling `.lock` file. +// The file itself is replaced atomically via rename. +func writeCompDB(path string, entry compdbEntry) error { + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("open compdb lock: %w", err) + } + defer lock.Close() + + if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf("lock compdb: %w", err) + } + + var db []compdbEntry + data, err := os.ReadFile(path) + switch { + case err == nil: + if err := json.Unmarshal(data, &db); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + case errors.Is(err, fs.ErrNotExist): + // new file + default: + return err + } + + i := slices.IndexFunc(db, func(e compdbEntry) bool { + return e.File == entry.File + }) + if i >= 0 { + db[i] = entry + } else { + db = append(db, entry) + } + + slices.SortFunc(db, func(a, b compdbEntry) int { + return strings.Compare(a.File, b.File) + }) + + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + enc := json.NewEncoder(tmp) + enc.SetIndent("", " ") + if err := enc.Encode(db); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/cmd/bpf2go/compdb_test.go b/cmd/bpf2go/compdb_test.go new file mode 100644 index 000000000..ec8f82faf --- /dev/null +++ b/cmd/bpf2go/compdb_test.go @@ -0,0 +1,117 @@ +//go:build !windows + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/go-quicktest/qt" +) + +func readCompDB(tb testing.TB, path string) []compdbEntry { + tb.Helper() + data, err := os.ReadFile(path) + qt.Assert(tb, qt.IsNil(err)) + var db []compdbEntry + qt.Assert(tb, qt.IsNil(json.Unmarshal(data, &db))) + return db +} + +func TestCompDBCreate(t *testing.T) { + path := filepath.Join(t.TempDir(), "compile_commands.json") + + entry := compdbEntry{ + Directory: "/tmp", + File: "/tmp/foo.c", + Arguments: []string{"clang", "-c", "/tmp/foo.c"}, + } + qt.Assert(t, qt.IsNil(writeCompDB(path, entry))) + + db := readCompDB(t, path) + qt.Assert(t, qt.HasLen(db, 1)) + qt.Assert(t, qt.Equals(db[0].File, entry.File)) + qt.Assert(t, qt.DeepEquals(db[0].Arguments, entry.Arguments)) +} + +func TestCompDBUpsert(t *testing.T) { + path := filepath.Join(t.TempDir(), "compile_commands.json") + + a1 := compdbEntry{Directory: "/tmp", File: "/tmp/a.c", Arguments: []string{"clang", "old"}} + b := compdbEntry{Directory: "/tmp", File: "/tmp/b.c", Arguments: []string{"clang", "b"}} + a2 := compdbEntry{Directory: "/tmp", File: "/tmp/a.c", Arguments: []string{"clang", "new"}} + + qt.Assert(t, qt.IsNil(writeCompDB(path, a1))) + qt.Assert(t, qt.IsNil(writeCompDB(path, b))) + qt.Assert(t, qt.IsNil(writeCompDB(path, a2))) + + db := readCompDB(t, path) + qt.Assert(t, qt.HasLen(db, 2)) + for _, e := range db { + if e.File == "/tmp/a.c" { + qt.Check(t, qt.DeepEquals(e.Arguments, []string{"clang", "new"})) + } + } +} + +func TestCompDBSorted(t *testing.T) { + path := filepath.Join(t.TempDir(), "compile_commands.json") + + for _, name := range []string{"/tmp/z.c", "/tmp/a.c", "/tmp/m.c"} { + qt.Assert(t, qt.IsNil(writeCompDB(path, compdbEntry{ + Directory: "/tmp", File: name, Arguments: []string{"clang"}, + }))) + } + + db := readCompDB(t, path) + qt.Assert(t, qt.HasLen(db, 3)) + qt.Check(t, qt.Equals(db[0].File, "/tmp/a.c")) + qt.Check(t, qt.Equals(db[1].File, "/tmp/m.c")) + qt.Check(t, qt.Equals(db[2].File, "/tmp/z.c")) +} + +func TestCompDBConcurrent(t *testing.T) { + path := filepath.Join(t.TempDir(), "compile_commands.json") + + const n = 16 + errs := make(chan error, n) + var wg sync.WaitGroup + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + errs <- writeCompDB(path, compdbEntry{ + Directory: "/tmp", + File: fmt.Sprintf("/tmp/f%02d.c", i), + Arguments: []string{"clang"}, + }) + }(i) + } + wg.Wait() + close(errs) + + for err := range errs { + qt.Check(t, qt.IsNil(err)) + } + + db := readCompDB(t, path) + qt.Assert(t, qt.HasLen(db, n)) +} + +func TestCompDBMalformedExisting(t *testing.T) { + path := filepath.Join(t.TempDir(), "compile_commands.json") + qt.Assert(t, qt.IsNil(os.WriteFile(path, []byte("{not json"), 0644))) + + err := writeCompDB(path, compdbEntry{ + Directory: "/tmp", File: "/tmp/a.c", Arguments: []string{"clang"}, + }) + qt.Assert(t, qt.IsNotNil(err)) + + data, rerr := os.ReadFile(path) + qt.Assert(t, qt.IsNil(rerr)) + qt.Assert(t, qt.Equals(string(data), "{not json")) +} diff --git a/cmd/bpf2go/gen/compile.go b/cmd/bpf2go/gen/compile.go index 954c81d00..09759c1bd 100644 --- a/cmd/bpf2go/gen/compile.go +++ b/cmd/bpf2go/gen/compile.go @@ -57,15 +57,13 @@ func insertDefaultFlags(flags []string) []string { return result } -// Compile C to a BPF ELF file. -func Compile(args CompileArgs) error { - cmd := exec.Command(args.CC, insertDefaultFlags(args.Flags)...) - cmd.Stderr = os.Stderr - +// Argv returns the compiler invocation that Compile would run for args. +// Compiler binary + passed flags. +func Argv(args CompileArgs) ([]string, error) { inputDir := filepath.Dir(args.Source) relInputDir, err := filepath.Rel(args.Workdir, inputDir) if err != nil { - return err + return nil, err } target := args.Target @@ -73,12 +71,14 @@ func Compile(args CompileArgs) error { target.clang = "bpf" } + argv := append([]string{args.CC}, insertDefaultFlags(args.Flags)...) + // C flags that can't be overridden. if linux := target.linux; linux != "" { - cmd.Args = append(cmd.Args, "-D__TARGET_ARCH_"+linux) + argv = append(argv, "-D__TARGET_ARCH_"+linux) } - cmd.Args = append(cmd.Args, + argv = append(argv, "-Wunused-command-line-argument", "-target", target.clang, "-c", args.Source, @@ -92,6 +92,19 @@ func Compile(args CompileArgs) error { "-g", fmt.Sprintf("-D__BPF_TARGET_MISSING=%q", "GCC error \"The eBPF is using target specific macros, please provide -target that is not bpf, bpfel or bpfeb\""), ) + + return argv, nil +} + +// Compile C to a BPF ELF file. +func Compile(args CompileArgs) error { + argv, err := Argv(args) + if err != nil { + return err + } + + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stderr = os.Stderr cmd.Dir = args.Workdir if err := cmd.Run(); err != nil { diff --git a/cmd/bpf2go/main.go b/cmd/bpf2go/main.go index 7d07c0303..52f6f402f 100644 --- a/cmd/bpf2go/main.go +++ b/cmd/bpf2go/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "io" + "maps" "os" "os/exec" "path/filepath" @@ -89,6 +90,9 @@ type bpf2go struct { // Base directory of the Makefile. Enables outputting make-style dependencies // in .d files. makeBase string + // Path to a JSON compilation database. If non-empty, an entry for the source + // file is upserted before compilation starts. + compdb string } func (b2g *bpf2go) Debugln(a ...any) { @@ -115,6 +119,8 @@ func newB2G(stdout io.Writer, args []string) (*bpf2go, error) { flagTarget := fs.String("target", "bpfel,bpfeb", "clang target(s) to compile for (comma separated)") fs.StringVar(&b2g.makeBase, "makebase", getEnv("BPF2GO_MAKEBASE", ""), "write make compatible depinfo files relative to `directory` ($BPF2GO_MAKEBASE)") + fs.StringVar(&b2g.compdb, "compdb", getEnv("BPF2GO_COMPDB", ""), + "upsert an entry for the source into compile_commands.json at `path` ($BPF2GO_COMPDB)") fs.Var(&b2g.cTypes, "type", "`Name` of a type to generate a Go declaration for, may be repeated") fs.BoolVar(&b2g.skipGlobalTypes, "no-global-types", false, "Skip generating types for map keys and values, etc.") fs.StringVar(&b2g.outputStem, "output-stem", "", "alternative stem for names of generated files (defaults to ident)") @@ -202,6 +208,13 @@ func newB2G(stdout io.Writer, args []string) (*bpf2go, error) { } } + if b2g.compdb != "" { + b2g.compdb, err = filepath.Abs(b2g.compdb) + if err != nil { + return nil, err + } + } + if b2g.outputStem != "" && strings.ContainsRune(b2g.outputStem, filepath.Separator) { return nil, fmt.Errorf("-output-stem %q must not contain path separation characters", b2g.outputStem) } @@ -307,6 +320,12 @@ func (b2g *bpf2go) convertAll() (err error) { return err } + if b2g.compdb != "" { + if err := b2g.writeCompDBEntry(); err != nil { + return fmt.Errorf("compdb: %w", err) + } + } + if !b2g.disableStripping { b2g.strip, err = exec.LookPath(b2g.strip) if err != nil { @@ -323,6 +342,46 @@ func (b2g *bpf2go) convertAll() (err error) { return nil } +func (b2g *bpf2go) writeCompDBEntry() error { + targets := slices.SortedFunc(maps.Keys(b2g.targetArches), func(a, b gen.Target) int { + return strings.Compare(a.Suffix(), b.Suffix()) + }) + tgt := targets[0] + + outputStem := b2g.outputStem + if outputStem == "" { + outputStem = strings.ToLower(b2g.identStem) + } + stem := fmt.Sprintf("%s_%s%s", outputStem, tgt.Suffix(), b2g.outputSuffix) + + absOutPath, err := filepath.Abs(b2g.outputDir) + if err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return err + } + + argv, err := gen.Argv(gen.CompileArgs{ + CC: b2g.cc, + Flags: b2g.cFlags, + Workdir: cwd, + Source: b2g.sourceFile, + Dest: filepath.Join(absOutPath, stem+".o"), + Target: tgt, + }) + if err != nil { + return err + } + + return writeCompDB(b2g.compdb, compdbEntry{ + Directory: filepath.Dir(b2g.sourceFile), + File: b2g.sourceFile, + Arguments: argv, + }) +} + 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 a7c960585..32d9265aa 100644 --- a/cmd/bpf2go/main_test.go +++ b/cmd/bpf2go/main_test.go @@ -125,6 +125,29 @@ func TestErrorMentionsEnvVar(t *testing.T) { qt.Assert(t, qt.StringContains(err.Error(), gopackageEnv), qt.Commentf("Error should include name of environment variable")) } +func TestCompDB(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, dir, "test.c", minimalSocketFilter) + dbPath := filepath.Join(dir, "compile_commands.json") + + err := run(io.Discard, []string{ + "-go-package", "foo", + "-output-dir", dir, + "-cc", testutils.ClangBin(t), + "-no-strip", + "-compdb", dbPath, + "bar", + filepath.Join(dir, "test.c"), + }) + qt.Assert(t, qt.IsNil(err)) + + db := readCompDB(t, dbPath) + qt.Assert(t, qt.HasLen(db, 1)) + qt.Check(t, qt.Equals(db[0].File, filepath.Join(dir, "test.c"))) + qt.Check(t, qt.SliceContains(db[0].Arguments, "-c")) + qt.Check(t, qt.SliceContains(db[0].Arguments, "-target")) +} + func TestDisableStripping(t *testing.T) { dir := t.TempDir() mustWriteFile(t, dir, "test.c", minimalSocketFilter) @@ -228,6 +251,31 @@ func TestParseArgs(t *testing.T) { qt.Assert(t, qt.Equals(b2g.makeBase, basePath)) }) + t.Run("compdb", func(t *testing.T) { + t.Setenv(gopackageEnv, pkg) + dbPath, _ := filepath.Abs("compile_commands.json") + args := []string{"-compdb", dbPath, stem, csource} + b2g, err := newB2G(&bytes.Buffer{}, args) + qt.Assert(t, qt.IsNil(err)) + qt.Assert(t, qt.Equals(b2g.compdb, dbPath)) + }) + + t.Run("compdb from env", func(t *testing.T) { + t.Setenv(gopackageEnv, pkg) + dbPath, _ := filepath.Abs("compile_commands.json") + t.Setenv("BPF2GO_COMPDB", dbPath) + b2g, err := newB2G(&bytes.Buffer{}, []string{stem, csource}) + qt.Assert(t, qt.IsNil(err)) + qt.Assert(t, qt.Equals(b2g.compdb, dbPath)) + }) + + t.Run("compdb unset", func(t *testing.T) { + t.Setenv(gopackageEnv, pkg) + b2g, err := newB2G(&bytes.Buffer{}, []string{stem, csource}) + qt.Assert(t, qt.IsNil(err)) + qt.Assert(t, qt.Equals(b2g.compdb, "")) + }) + t.Run("makebase flag overrides env", func(t *testing.T) { t.Setenv(gopackageEnv, pkg) basePathFlag, _ := filepath.Abs("barfoo")