Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .claude/maintain.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@
- `make test-perf` (L5)
- `make ci` runs every layer and prints a per-layer summary.
- Benchmark results must never get worse. CI's "Bench (compare to
main)" job runs `scripts/bench-ab.sh` (interleaved main/HEAD rounds,
Go benchmarks + BPF hot-path `BenchmarkBPF`) and
`scripts/bench-gate.sh`, which fails on any significant regression.
main)" job runs `scripts/bench-check.sh`: interleaved main/HEAD
rounds (`bench-ab.sh`, each side on its own go.mod toolchain; Go
benchmarks + BPF hot-path `BenchmarkBPF`), gated by `bench-gate.sh`.
Any significant regression triggers a second A/B; it fails if the
same benchmark regresses again.
- Sequential local microbenchmarks are unreliable on a laptop (the same
binary measured 60% apart an hour later). Compare interleaved, and
measure CNI invoke RSS with many interleaved `/usr/bin/time -v` runs
rather than the k3d rig's 3-sample poll.
For dataplane changes also compare `PERF_PROFILE=full make
perf-vs-vanilla` on main vs the branch before merging.
- Head-to-head numbers: `make perf-vs-vanilla` (k3d, ~20 min) and the
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
# `make pre-commit` is the target the .githooks/pre-commit hook
# invokes locally. Running the same target here means anything
# caught locally is caught here, and vice versa — no drift. The
# Makefile pins golangci-lint v2.5.0 (handles go.mod's 1.26
# Makefile pins golangci-lint v2.14.0 (handles go.mod's 1.27
# requirement) and installs it into ./bin via go-install.
- run: make pre-commit

Expand Down
13 changes: 6 additions & 7 deletions .github/workflows/unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
go-version-file: go.mod
- uses: ./.github/actions/build-bpf
- name: Run unit tests
run: go test -race ./pkg/... -coverprofile cover.out
run: go test -race ./pkg/... ./internal/... ./cmd/... -coverprofile cover.out
- name: Upload coverage
uses: codecov/codecov-action@v4
if: always()
Expand Down Expand Up @@ -69,15 +69,14 @@ jobs:
cd "$RUNNER_TEMP/base"
make build-bpf
cp bpf/natra.bpf.o pkg/bpf/natra.bpf.o
# Interleaved rounds, 10 samples per side. Benchmark results must
# never get worse: the gate fails on any significant regression.
# Interleaved rounds, 10 samples per side, each side on its own
# go.mod toolchain. Benchmark results must never get worse: any
# significant regression that reproduces on a second A/B fails.
- name: Bench main vs HEAD
run: scripts/bench-ab.sh "$RUNNER_TEMP/base" . bench-main.txt bench-head.txt
- name: Regression gate
run: scripts/bench-gate.sh bench-main.txt bench-head.txt
run: scripts/bench-check.sh "$RUNNER_TEMP/base" . bench-out
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: bench-results
path: bench-*.txt
path: bench-out/
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ linters:
- dupl
- lll
path: internal/*
# Verbatim copy of libcni's skel (see internal/cniskel/skel.go);
# kept unrestyled so it diffs cleanly against upstream.
- linters:
- goconst
- gocyclo
path: internal/cniskel/
paths:
- third_party$
- builtin$
Expand Down
20 changes: 11 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ BPF_CLANG ?= clang
endif

CNI_BINARY := bin/natra
TOOLS_BINARY := bin/natra-tools
BPF_OBJS := bpf/natra.bpf.o bpf/placeholder.bpf.o bpf/vanilla.bpf.o
# Intentionally-invalid programs used by the L3 chaos suite. They MUST
# build (clang accepts them) but FAIL to load (verifier rejects). Listed
Expand Down Expand Up @@ -137,7 +138,8 @@ build-cni-inner:
@mkdir -p bin pkg/bpf
@cp bpf/natra.bpf.o pkg/bpf/natra.bpf.o
@GOOS=linux CGO_ENABLED=0 go build -buildvcs=false -o $(CNI_BINARY) ./cmd/natra
@echo "Built $(CNI_BINARY) ($$(file $(CNI_BINARY) | cut -d',' -f2))"
@GOOS=linux CGO_ENABLED=0 go build -buildvcs=false -o $(TOOLS_BINARY) ./cmd/natra-tools
@echo "Built $(CNI_BINARY) ($$(file $(CNI_BINARY) | cut -d',' -f2)) and $(TOOLS_BINARY)"

.PHONY: build-bpf
build-bpf: $(BPF_OBJS) $(BPF_TESTDATA_OBJS) ## Compile BPF C sources (incl. chaos testdata) to bytecode (.o).
Expand Down Expand Up @@ -177,7 +179,7 @@ bpf/%.bpf.o: bpf/%.bpf.c

.PHONY: clean
clean: ## Clean build artifacts.
rm -f $(CNI_BINARY) $(BPF_OBJS) $(BPF_TESTDATA_OBJS) cover.out
rm -f $(CNI_BINARY) $(TOOLS_BINARY) $(BPF_OBJS) $(BPF_TESTDATA_OBJS) cover.out

##@ Testing

Expand All @@ -186,7 +188,7 @@ test: test-unit ## Default test target — Layer 1 only (fast).

.PHONY: test-unit
test-unit: ## Layer 1a — Ginkgo unit tests.
go test ./pkg/... ./internal/... -coverprofile cover.out
go test ./pkg/... ./internal/... ./cmd/... -coverprofile cover.out

.PHONY: test-fuzz
test-fuzz: ## Layer 1b — 30s fuzz against the bandwidth annotation parser.
Expand Down Expand Up @@ -322,12 +324,12 @@ $(LOCALBIN):
mkdir -p $(LOCALBIN)

GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
# v2.5.0 handles natra's go.mod (go 1.26). v2.3.0 was built against
# go 1.24 and rejects the config when run against newer-targeted code
# — see ci.yml for the same constraint. Newer v2.12.x enables stricter
# linters (goconst/prealloc) that would flag unrelated pre-existing
# test code; hold here until those are addressed deliberately.
GOLANGCI_LINT_VERSION ?= v2.5.0
# golangci-lint must be built with a Go at least as new as go.mod's
# directive, and its bundled x/tools must read that Go's export data.
# v2.5.0 fails on go 1.27 ("export data version 4 is greater than
# maximum supported version 2"); v2.14.0 handles it. See ci.yml for
# the same constraint.
GOLANGCI_LINT_VERSION ?= v2.14.0

# golangci-lint refuses to run when built with a Go older than
# go.mod's directive, so the cached binary is keyed on the Go version
Expand Down
4 changes: 2 additions & 2 deletions cmd/natra/dump_linux.go → cmd/natra-tools/dump_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func dumpStats(args []string) error {
}

open := func(name string) (*ebpf.Map, error) {
path := filepath.Join(pinDir, containerID+"-"+name+"-map")
path := filepath.Join(bpf.PinDir, containerID+"-"+name+"-map")
return ebpf.LoadPinnedMap(path, nil)
}

Expand Down Expand Up @@ -151,7 +151,7 @@ func dumpStats(args []string) error {
}

// Self-check: pin path stats can drift from /sys/fs/bpf/natra layout.
if entries, err := os.ReadDir(pinDir); err == nil {
if entries, err := os.ReadDir(bpf.PinDir); err == nil {
var pinned []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), containerID) {
Expand Down
File renamed without changes.
File renamed without changes.
41 changes: 41 additions & 0 deletions cmd/natra-tools/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// natra-tools holds natra's operator subcommands. They live outside
// the CNI binary so the per-pod `natra` invocation doesn't page in
// their code (runtime/pprof alone is ~100 KB of peak RSS). `natra
// <subcommand>` execs this binary from the same directory, so the
// documented commands are unchanged.
package main

import (
"fmt"
"os"
)

const usage = `usage: natra-tools <command> [args]

commands:
install-cni-chain <conflist-dir> chain natra into existing conflists
dump-stats <containerID> print a pod's pinned BPF stats
profile [flags] sample BPF program runtime stats`

func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, usage)
os.Exit(64)
}
var err error
switch os.Args[1] {
case "install-cni-chain":
err = installCNIChain(os.Args[2:])
case "dump-stats":
err = dumpStats(os.Args[2:])
case "profile":
err = profileCmd(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "natra-tools: unknown command %q\n\n%s\n", os.Args[1], usage)
os.Exit(64)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import (

"github.com/cilium/ebpf"
"golang.org/x/sys/unix"

"github.com/terraboops/natra/pkg/bpf"
)

// progSnapshot is a per-program datum at a single tick. Runtime/RunCount
Expand Down Expand Up @@ -92,7 +94,7 @@ func profileCmd(args []string) error {
interval := fs.Duration("interval", 5*time.Second, "snapshot interval")
output := fs.String("output", "", "JSONL output file (default stdout)")
heapDir := fs.String("heap-dir", "", "if set, write a heap pprof of this process per tick")
pinPath := fs.String("pin-dir", pinDir, "bpffs directory where natra pins maps")
pinPath := fs.String("pin-dir", bpf.PinDir, "bpffs directory where natra pins maps")
once := fs.Bool("once", false, "take a single snapshot and exit (foreground use; soak rig)")
if err := fs.Parse(args); err != nil {
return err
Expand Down
File renamed without changes.
38 changes: 38 additions & 0 deletions cmd/natra/deps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"os"
"os/exec"
"strings"
"testing"
)

// The CNI binary runs once per pod sandbox, and its peak RSS scales
// with how much of the binary gets paged in. These packages cost
// hundreds of KB each without being needed on the CNI path: regexp
// (compiled at init by cni/pkg/utils, hence internal/cniskel),
// runtime/pprof (natra-tools, or -tags heapprofile).
var forbiddenInPlugin = []string{
"regexp",
"runtime/pprof",
"github.com/containernetworking/cni/pkg/skel",
"github.com/containernetworking/cni/pkg/utils",
}

func TestPluginDoesNotLinkHeavyPackages(t *testing.T) {
cmd := exec.Command("go", "list", "-deps", ".")
cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
out, err := cmd.Output()
if err != nil {
t.Skipf("go list unavailable: %v", err)
}
deps := map[string]bool{}
for _, p := range strings.Fields(string(out)) {
deps[p] = true
}
for _, p := range forbiddenInPlugin {
if deps[p] {
t.Errorf("cmd/natra links %s; keep it out of the CNI binary (see deps_test.go)", p)
}
}
}
40 changes: 40 additions & 0 deletions cmd/natra/heapprofile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//go:build heapprofile

package main

import (
"fmt"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"time"
)

// maybeWriteHeapProfile dumps the Go heap profile of the natra CNI
// process at end of cmdAdd when NATRA_HEAP_PROFILE_DIR is set in the
// environment. Only in builds with `-tags heapprofile`: linking
// runtime/pprof into every CNI invocation costs ~100 KB of peak RSS. Files land at <dir>/cmdadd-<unixnano>-<containerID>.pprof,
// one per invocation — kubelet typically calls a CNI plugin once per
// pod sandbox, so the file count grows with pod churn.
//
// Aggregated across many ADDs during the perf-vs-vanilla rig, the
// profile shows what natra allocates per-invocation; useful for
// catching allocator regressions in the ADD hot path.
func maybeWriteHeapProfile(containerID string) {
dir := os.Getenv("NATRA_HEAP_PROFILE_DIR")
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
runtime.GC()
path := filepath.Join(dir, fmt.Sprintf("cmdadd-%d-%s.pprof", time.Now().UnixNano(), containerID))
f, err := os.Create(path)
if err != nil {
return
}
defer func() { _ = f.Close() }()
_ = pprof.WriteHeapProfile(f)
}
6 changes: 6 additions & 0 deletions cmd/natra/heapprofile_off.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !heapprofile

package main

// maybeWriteHeapProfile is a no-op unless built with -tags heapprofile.
func maybeWriteHeapProfile(string) {}
Loading
Loading