diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..28192a0 --- /dev/null +++ b/.clangd @@ -0,0 +1,17 @@ +# Tell clangd how the BPF units are compiled (mirrors build/bpf.mk), so the +# editor resolves vmlinux.h, the libbpf headers, and the __u* types instead of +# flagging them. vmlinux.h is generated by `make`, so run it once for full +# resolution. +# +# -I../v/include points at the vendored libbpf SDK headers when editing inside +# the bootstrap repo (where v/ is a sibling). In a scaffolded project the +# headers live in the shared toolchain cache instead; clangd harmlessly +# ignores the missing path there. clangd resolves these relative to the +# project root (the compile working directory). +CompileFlags: + Add: + - -target + - bpf + - -Isrc/bpf/include + - -I../v/include + - -D__BPF_TRACING__ diff --git a/.github/workflows/kernel-matrix.yml b/.github/workflows/kernel-matrix.yml new file mode 100644 index 0000000..a6f4743 --- /dev/null +++ b/.github/workflows/kernel-matrix.yml @@ -0,0 +1,198 @@ +name: kernel-matrix + +# Build the BPF object once per job, then boot a range of kernels and confirm +# each one's verifier accepts every program in bin/probe.bpf.o. The check is +# the vendored static `veristat` (it loads each program and reports a verdict); +# kernels come from cilium's little-vm-helper (quay.io/lvh-images), booted under +# QEMU/KVM on the runner. Each job writes a detail table to its step summary and +# uploads its result; the final `matrix` job pivots them into one ✅/❌ grid. +# +# Tune `matrix.kernel` to the kernel lines your script must support (`6.6`, +# `bpf-next`, …). Each line is resolved to a concrete image at run time rather +# than using the floating `-main` tag, which the action can't consume: +# little-vm-helper@v0.0.30 derives the VM image filename by stripping a trailing +# *numeric* build stamp, so a `-main` tag yields a name that doesn't match the +# file `lvh` actually unpacks and the run dies with "invalid reference format". +# So each job looks up the newest date-stamped tag (`-YYYYMMDD.HHMMSS`, +# which the action handles) from the quay registry — always tracking the latest +# build, with no tag to bump and immune to quay's pruning of old stamps. httptop +# attaches at the TC layer via TCX (Linux 6.6+), so the list starts at 6.6 — +# older kernels can't load the tcx/* programs at all. + +on: + workflow_dispatch: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Kernel lines to verify. Each is resolved to its newest date-stamped + # lvh image at run time (see the header). httptop needs TCX (Linux + # 6.6+), so the list starts at 6.6 — older kernels can't load the tcx/* + # programs at all. + kernel: + - '6.6' # oldest LTS with TCX (Linux 6.6) — the floor + - '6.12' # LTS + - '6.18' # recent mainline + - 'bpf-next' + name: kernel ${{ matrix.kernel }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve newest lvh image tag + id: img + env: + KERNEL: ${{ matrix.kernel }} + run: | + set -euo pipefail + # Newest -YYYYMMDD.HHMMSS tag (date-stamps sort + # lexicographically, so tail -1 is the most recent build). + newest="$(curl -sf "https://quay.io/api/v1/repository/lvh-images/kind/tag/?onlyActiveTags=true&limit=100&filter_tag_name=like:${KERNEL}-" \ + | jq -r '.tags[].name' \ + | grep -E "^${KERNEL}-[0-9]{8}\.[0-9]+$" | sort | tail -1)" + [ -n "$newest" ] || { echo "::error::no date-stamped tag found for kernel line '${KERNEL}'"; exit 1; } + echo "resolved ${KERNEL} -> ${newest}" + echo "tag=${newest}" >> "$GITHUB_OUTPUT" + + - name: Build BPF object + stage veristat + run: | + set -euo pipefail + # Builds bin/probe.bpf.o with the vendored static toolchain, also + # populating the per-machine toolchain cache (clang/bpftool/veristat). + make bpf + + # Resolve the vendored static veristat the same way build/toolchain.mk + # does, and stage it into bin/ so the VM finds it under /host. It is + # fully static, so it runs in any kernel image's rootfs. + . build/toolchain.lock + arch="$(uname -m)"; [ "$arch" = arm64 ] && arch=aarch64 + cache="${XDG_CACHE_HOME:-$HOME/.cache}/yeet/toolchain/v${TOOLCHAIN_VERSION}/${arch}" + if [ ! -x "$cache/veristat" ]; then + echo "::error::veristat is not in the pinned toolchain (v${TOOLCHAIN_VERSION}). Bump build/toolchain.lock to a toolchain release that ships veristat." + exit 1 + fi + install -Dm755 "$cache/veristat" bin/veristat + file bin/veristat bin/probe.bpf.o + + - name: Verify on kernel ${{ matrix.kernel }} + uses: cilium/little-vm-helper@v0.0.30 + with: + test-name: veristat-${{ matrix.kernel }} + image: kind + image-version: ${{ steps.img.outputs.tag }} + host-mount: ${{ github.workspace }} + install-dependencies: 'true' + cmd: | + cd /host + OUT_CSV=/host/.kmatrix/result.csv sh build/verify-kernel.sh + + - name: Render kernel summary + if: always() + env: + KVER: ${{ matrix.kernel }} + KCSV: ${{ github.workspace }}/.kmatrix/result.csv + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import csv, os + kver, path = os.environ["KVER"], os.environ["KCSV"] + if not os.path.exists(path): + print(f"### kernel `{kver}` — ⚠️ no result (build or boot failed)\n") + raise SystemExit + rows = list(csv.DictReader(open(path))) + mark = lambda v: "✅" if v == "success" else "❌" + ok = all(r["verdict"] == "success" for r in rows) + head = "✅ all programs loaded" if ok else "❌ verifier rejected a program" + print(f"### kernel `{kver}` — {head}\n") + print("| Program | Verdict | Insns | States |") + print("|---|:---:|--:|--:|") + for r in rows: + print(f"| `{r['prog_name']}` | {mark(r['verdict'])} | {r['total_insns']} | {r['total_states']} |") + print() + PY + + - name: Upload result + if: always() + uses: actions/upload-artifact@v4 + with: + name: kmatrix-${{ matrix.kernel }} + path: ${{ github.workspace }}/.kmatrix/result.csv + if-no-files-found: ignore + + matrix: + needs: verify + if: always() + runs-on: ubuntu-latest + name: matrix summary + steps: + - uses: actions/download-artifact@v4 + with: + path: results + pattern: kmatrix-* + + - name: Render matrix + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import csv, glob, os, re + + # One CSV per kernel under results/kmatrix-/result.csv. + data, kernels, progs = {}, [], [] + for d in sorted(glob.glob("results/kmatrix-*")): + kver = os.path.basename(d)[len("kmatrix-"):] + f = os.path.join(d, "result.csv") + if not os.path.exists(f): + data[kver] = None + kernels.append(kver) + continue + data[kver] = {r["prog_name"]: r["verdict"] for r in csv.DictReader(open(f))} + kernels.append(kver) + for p in data[kver]: + if p not in progs: + progs.append(p) + + # Order kernels by version, bpf-next last. + def keyf(k): + m = re.match(r"(\d+)\.(\d+)", k) + return (1, 0, 0) if not m else (0, int(m.group(1)), int(m.group(2))) + kernels.sort(key=keyf) + short = lambda k: re.sub(r"-(main|\d{8}\.\d+)$", "", k) + + print("## 🐧 Kernel verification matrix\n") + if not progs: + print("⚠️ No results were produced — check the per-kernel job logs.\n") + raise SystemExit + print("| Program | " + " | ".join(short(k) for k in kernels) + " |") + print("|---|" + "|".join(":-:" for _ in kernels) + "|") + fail = 0 + for p in progs: + cells = [] + for k in kernels: + d = data[k] + if d is None or p not in d: + cells.append("⚪") + elif d[p] == "success": + cells.append("✅") + else: + cells.append("❌"); fail += 1 + print(f"| `{p}` | " + " | ".join(cells) + " |") + print() + print("✅ accepted · ❌ rejected · ⚪ not run\n") + total = len(progs) * len([k for k in kernels if data[k] is not None]) + verb = "all programs loaded on every kernel" if fail == 0 else f"{fail} of {total} program×kernel checks failed" + print(f"**{len(progs)} program(s) × {len(kernels)} kernel(s) — {verb}.**") + PY + + - name: Gate on any rejection + run: | + # Fail the run if any per-kernel job failed (a rejection or a build/boot error). + if [ "${{ contains(needs.verify.result, 'failure') }}" = "true" ] || [ "${{ needs.verify.result }}" = "failure" ]; then + echo "::error::one or more kernels rejected a program (see the matrix summary)" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 5fc53cb..e768112 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,15 @@ # build artifacts -/bin/ -*.o -*.o.tmp +/node_modules/ +/src/index.jsx +/.build/ -# generated from the running kernel's BTF (run `make` to regenerate) -/include/vmlinux.h +# compiled BPF objects + generated CO-RE header +/bin/* +!/bin/.gitkeep +/src/bpf/include/vmlinux.h + +# kernel-matrix run output (build/kernel-matrix.sh) +/.kmatrix/ # python bytecode (demo/) __pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..34ce154 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,668 @@ +# Building yeet dashboards + +This is a **yeet** script: a reactive JSX TUI that runs in the daemon's V8 +isolate, fed by live kernel data (eBPF + a process/system graph). This file +is the API contract and gotcha list for editing it. For build/run mechanics, +layout, and the `@/`/`#/` aliases, see `README.md` — don't duplicate that here. + +## Mental model + +It reads like React but it is **signals, not a vdom**. No hooks, no +reconciliation, no `useState`. A node re-renders exactly when a signal it +*read* changes — and the only way to "read inside a node" is to pass a +**thunk** (`() => …`) as a prop or child. A plain value is static forever; a +thunk is reactive. + +```jsx +{() => `load ${load.get().toFixed(2)}`} // re-renders on load change +{`load ${load.get()}`} // snapshot, never updates +``` + +Three layers, composed: + +``` +probes/ (BPF-aware) → signals → components/ (pure UI, read signals) + ↑ + graph queries / timers +``` + +`probes/` is the *only* code that touches `yeet:bpf`; it exposes plain +signals. Components never see BPF — they read signals. `lib/` is pure helpers. + +## Build bottom-up: data → component → layout + +Build a dashboard from the inside out. Each layer is verifiable on its own, so +mistakes surface where they're cheap — at the data, not three layers up where a +blank panel could mean anything. + +### 1. Get the data right first, in isolation + +Before any JSX, confirm the kernel actually gives you the fields and types you +think it does. Guard a self-test with `import.meta.main` — it's `true` **only** +when this module is the run entry, so the block runs when you point `yeet run` +at the module and stays dormant once `main.jsx` imports it. + +Verify the **raw source**, not a `from()` signal (a `from()` producer doesn't +run until something watches it — there's no UI here): + +```js +// probes/conns.js +import { BpfObject, RingBuf } from "yeet:bpf"; +import { from } from "yeet:tui"; + +const ctl = await new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname }) + .bind("events", { kind: "ringbuf", btf_struct: "conn_event" }) + .start(); +const events = new RingBuf(ctl, "events"); + +export const conns = from((state) => { /* …wrap events into a signal… */ }, []); + +// Standalone correctness probe — dumps real records so you can eyeball field +// names, the btf_struct envelope, and which numbers came back as BigInt. +if (import.meta.main) { + await events.subscribe((w) => console.log(JSON.stringify(w, (_k, v) => + typeof v === "bigint" ? `${v}n` : v))); // JSON.stringify chokes on BigInt +} +``` + +For a graph probe the self-test is a one-shot dump: + +```js +if (import.meta.main) { + const { data } = await yeet.graph.query(QUERY); + console.log(JSON.stringify(data, null, 2)); + yeet.exit(); +} +``` + +Run it directly — `yeet run src/probes/conns.js`. **Caveat:** `@/` and `#/` are +bundle-time aliases, so a standalone module must reach its siblings by relative +path (`./probe.js`), or be bundled as its own entry. Switching `JSON.stringify` +to flag BigInt up front saves you the "why does math give NaN" detour — wrap +64-bit values with `Number(...)` once you've seen them. + +### 2. Build each component against a fake signal + +A component is a pure function of signals, so prove it in isolation with a +hand-fed signal before any real data exists. Mount just the one: + +```jsx +// scratch entry while developing components/gauge.jsx +import { mount, signal } from "yeet:tui"; +import Gauge from "@/components/gauge.jsx"; + +const fake = signal(0.3); +setInterval(() => fake.set(Math.random()), 700); // exercise the reactive path +mount(() => ); +await new Promise(() => {}); +``` + +You're checking one thing: does it repaint when the signal changes, and does it +fit its box? Get sizing and the thunk wiring right here, with data you control, +before it has to share the screen. + +### 3. Layout and routing last + +Only once the pieces work do you compose them. The layout is a single thunk +that reads the size signal (reflow on resize) and a view signal (which panel is +showing) — responsive breakpoints and "routing" are the same branch: + +```jsx +const view = signal("cpu"); +tty.on("keydown", (e) => { + if (e.key === "1") view.set("cpu"); + else if (e.key === "2") view.set("net"); +}); + +const Root = (size) => ( + + + + {() => { + const { cols } = size.get(); + if (cols < 80) return ; // responsive + switch (view.get()) { // routing + case "cpu": return ; + case "net": return ; + } + }} + +