From 0dc3dc4e0c8367bc8399d3987251015a0e135fd9 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 1 May 2026 10:02:19 +0300 Subject: update instructions --- AGENTS.md | 7 +++ Magefile.go | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 94 +++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ddfdbbf..0d65300 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,15 @@ env GOTOOLCHAIN=auto mage bench # Run benchmarks env GOTOOLCHAIN=auto mage prReview # Run PR review baseline: world + benchProf env GOTOOLCHAIN=auto mage clean # Clean build artifacts env GOTOOLCHAIN=auto mage world # Clean + generate + test + build (recommended reset path) +env GOTOOLCHAIN=auto mage demo # Regen demo/ GIFs + screenshots (needs vhs+ttyd, sudo -v warmed) +TAPE=07-stream-live mage demoOne # Regen one demo tape only +env GOTOOLCHAIN=auto mage installDemoTools # One-time: install vhs (go install) and ttyd (dnf) ``` +## Demo Pipeline + +`demo/` holds the reproducible TUI demo: 14 [VHS](https://github.com/charmbracelet/vhs) `.tape` files under `demo/tapes/` drive every dashboard tab and headless mode, write GIFs and PNGs into `demo/assets/`, and the resulting tutorial is `demo/TUTORIAL.md`. Background workload is generated by `demo/scripts/workload.sh`. `mage demo` is fully headless (no real terminal window) — safe to run in the background while editing code; the only foreground requirement is one `sudo -v` to pre-warm the sudo timestamp. + ## Code Generation **Run `mage generate` before building when tracepoint definitions change!** diff --git a/Magefile.go b/Magefile.go index fa04d31..cf1adfb 100644 --- a/Magefile.go +++ b/Magefile.go @@ -1145,3 +1145,165 @@ func runParquetChecks(dir, file string) error { return nil } + +// --- Demo (VHS-driven TUI recordings) --------------------------------------- +// +// Demo regenerates GIFs and PNG screenshots under demo/assets/ by running every +// VHS tape under demo/tapes/. Each tape is wrapped by demo/scripts/run-tape.sh +// which spins up the background workload generator. A sudo keep-alive loop in +// the background of `mage demo` keeps the sudo timestamp warm so no tape ever +// blocks on a password prompt. + +const ( + demoDir = "demo" + demoTapesDir = "demo/tapes" + demoScriptsDir = "demo/scripts" + demoRunTape = "demo/scripts/run-tape.sh" + demoSudoKeepers = "demo/scripts/sudo-keepalive.sh" +) + +// Demo regenerates every demo asset (full ~14-tape run, ~10 minutes). +// Pre-flight: vhs + ttyd on PATH, sudo timestamp live (`sudo -v`). +// Safe to run in the background — VHS records headlessly with no real window. +func Demo() error { + mg.SerialDeps(Build) + if err := buildWorkloadBinary(); err != nil { + return err + } + if err := ensureDemoTooling(); err != nil { + return err + } + if err := ensureSudoTimestamp(); err != nil { + return err + } + + tapes, err := filepath.Glob(filepath.Join(demoTapesDir, "*.tape")) + if err != nil { + return fmt.Errorf("glob tapes: %w", err) + } + if len(tapes) == 0 { + return fmt.Errorf("no tape files found under %s", demoTapesDir) + } + slices.Sort(tapes) + + stopKeepalive, err := startSudoKeepalive() + if err != nil { + return fmt.Errorf("start sudo keep-alive: %w", err) + } + defer stopKeepalive() + + fmt.Printf("Demo: regenerating %d tapes...\n", len(tapes)) + for i, tape := range tapes { + fmt.Printf("Demo: [%d/%d] %s\n", i+1, len(tapes), tape) + if err := sh.RunV(demoRunTape, tape); err != nil { + return fmt.Errorf("tape %s: %w", tape, err) + } + } + fmt.Println("Demo: done.") + return nil +} + +// DemoOne regenerates a single tape. Pass TAPE=07-stream-live (basename without +// .tape) or TAPE=demo/tapes/07-stream-live.tape (full path). +func DemoOne() error { + mg.SerialDeps(Build) + if err := buildWorkloadBinary(); err != nil { + return err + } + if err := ensureDemoTooling(); err != nil { + return err + } + if err := ensureSudoTimestamp(); err != nil { + return err + } + + tape := os.Getenv("TAPE") + if tape == "" { + return fmt.Errorf("TAPE env var is required (e.g. TAPE=07-stream-live)") + } + resolved, err := resolveDemoTape(tape) + if err != nil { + return err + } + fmt.Printf("Demo: regenerating %s\n", resolved) + return sh.RunV(demoRunTape, resolved) +} + +// InstallDemoTools installs VHS via `go install` and ttyd via dnf. Idempotent. +func InstallDemoTools() error { + if _, err := exec.LookPath("vhs"); err != nil { + fmt.Println("InstallDemoTools: installing vhs via `go install`...") + if err := sh.RunV("go", "install", "github.com/charmbracelet/vhs@latest"); err != nil { + return fmt.Errorf("install vhs: %w", err) + } + } else { + fmt.Println("InstallDemoTools: vhs already installed.") + } + if _, err := exec.LookPath("ttyd"); err != nil { + fmt.Println("InstallDemoTools: installing ttyd via dnf (sudo)...") + if err := sh.RunV("sudo", "dnf", "install", "-y", "ttyd"); err != nil { + return fmt.Errorf("install ttyd: %w", err) + } + } else { + fmt.Println("InstallDemoTools: ttyd already installed.") + } + if err := sh.RunV("vhs", "--version"); err != nil { + return err + } + return sh.RunV("ttyd", "--version") +} + +func ensureDemoTooling() error { + for _, bin := range []string{"vhs", "ttyd"} { + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("%s not on PATH (run `mage installDemoTools`)", bin) + } + } + return nil +} + +// ensureSudoTimestamp returns nil only if `sudo -n true` succeeds. Otherwise it +// asks the user to pre-warm sudo and aborts. +func ensureSudoTimestamp() error { + if os.Geteuid() == 0 { + return nil + } + if err := sh.Run("sudo", "-n", "true"); err != nil { + return fmt.Errorf("sudo timestamp not warm — run `sudo -v` once and re-invoke this target") + } + return nil +} + +// startSudoKeepalive launches the sudo-keepalive script in the background and +// returns a stop function that terminates it. +func startSudoKeepalive() (func(), error) { + if os.Geteuid() == 0 { + return func() {}, nil + } + cmd := exec.Command("bash", demoSudoKeepers) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, err + } + return func() { + _ = cmd.Process.Signal(os.Interrupt) + _, _ = cmd.Process.Wait() + }, nil +} + +// resolveDemoTape accepts a bare tape stem ("07-stream-live") or a full path +// and returns the existing tape file under demo/tapes/. +func resolveDemoTape(tape string) (string, error) { + candidates := []string{ + tape, + filepath.Join(demoTapesDir, tape), + filepath.Join(demoTapesDir, tape+".tape"), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c, nil + } + } + return "", fmt.Errorf("tape not found: tried %v", candidates) +} diff --git a/README.md b/README.md index 58d0a47..4462b8a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,16 @@ Maybe this is a spiritual successor of one of my previous projects, I/O Riot htt This works only on Linux! +## Demo + +A short guided tour with animated GIFs of every major surface lives in [`demo/TUTORIAL.md`](./demo/TUTORIAL.md). Two teasers: + +Cold start: PID picker, then the dashboard appears + +Live in-TUI flamegraph rebuilding from real workload + +The demo is fully reproducible: `mage installDemoTools` once, then `sudo -v && mage demo` regenerates every GIF and screenshot. See the [tutorial](./demo/TUTORIAL.md) for the full walkthrough. + ## Requirements - Go 1.26 or newer (ior relies on cgo via libbpfgo). @@ -101,6 +111,90 @@ make sudo cp -v ./libelf/libelf.a /usr/lib64/ ``` +## Rocky Linux 9 + +Verified on a fresh Rocky Linux 9.7 install (kernel `5.14.0-611.5.1.el9_7`). Two +caveats up front before the steps: + +1. The stock RHEL 9 kernel (`5.14`) ships a partial backport of BPF features. Specifically, + `BPF_LINK_CREATE` for `BPF_PERF_EVENT` returns `EACCES` even as root, so `ior` can load + the BPF object but cannot attach tracepoints. This is a kernel-side issue, not an `ior` + issue (`bpftrace` works because it uses the older `PERF_EVENT_IOC_SET_BPF` ioctl path). + The fix below installs `kernel-ml` from ElRepo (`7.0.x` mainline) and reboots into it. +2. Rocky 9 ships neither `libelf.a` nor `libzstd.a` (no `*-static` packages). Both have + to be built from source — the elfutils dance is the same as the Fedora section above; + `libzstd.a` needs an extra `make` from the upstream tarball. + +```shell +# 1) Enable repos and install build dependencies (CRB ships static libs). +sudo dnf config-manager --set-enabled crb +sudo dnf install -y epel-release +sudo dnf install -y gcc clang bpftool elfutils-libelf-devel zlib-static \ + glibc-static libzstd-devel git make cmake wget rpmdevtools strace bpftrace +sudo dnf builddep -y elfutils + +# 2) Install Go 1.26 from go.dev (Rocky 9 ships only Go 1.25; ior needs 1.26+). +cd /tmp +wget -q https://go.dev/dl/go1.26.2.linux-amd64.tar.gz +sudo tar -C /usr/local -xf go1.26.2.linux-amd64.tar.gz +echo 'export PATH=/usr/local/go/bin:$HOME/go/bin:$PATH' | sudo tee /etc/profile.d/go.sh +source /etc/profile.d/go.sh + +# 3) Build libelf.a from elfutils source (same trick as the Fedora section). +mkdir -p ~/src && cd ~ +dnf download --source elfutils-libelf +rpm -ivh elfutils-*.src.rpm +tar -C ~/src -xjf rpmbuild/SOURCES/elfutils-*.tar.bz2 +cd ~/src/elfutils-* +./configure --enable-deterministic-archives --disable-debuginfod --disable-libdebuginfod +make -C lib -j$(nproc) +make -C libelf -j$(nproc) +sudo cp -v libelf/libelf.a /usr/lib64/ + +# 4) Build libzstd.a from upstream (libzstd-devel does not ship the static archive). +cd /tmp +wget -q https://github.com/facebook/zstd/releases/download/v1.5.5/zstd-1.5.5.tar.gz +tar xzf zstd-1.5.5.tar.gz +make -C zstd-1.5.5/lib -j$(nproc) libzstd.a +sudo cp -v zstd-1.5.5/lib/libzstd.a /usr/lib64/ + +# 5) Install kernel-ml from ElRepo and reboot into it. +sudo rpm --import https://www.elrepo.org/RPM-GPG-KEY-elrepo.org +sudo dnf install -y https://www.elrepo.org/elrepo-release-9.el9.elrepo.noarch.rpm +sudo dnf --enablerepo=elrepo-kernel install -y kernel-ml +# kernel-ml becomes the default boot entry automatically (grubby --default-kernel +# after install reports /boot/vmlinuz-7.x...). Old kernel stays available as a +# fallback boot entry in case the new one misbehaves. +sudo reboot + +# After reboot: +uname -r # should be 7.x.x-... (kernel-ml), not 5.14.x + +# 6) Clone ior + libbpfgo, pin libbpfgo, build the static archive, install mage. +mkdir -p ~/git +git clone https://codeberg.org/snonux/ior ~/git/ior +git clone https://github.com/aquasecurity/libbpfgo ~/git/libbpfgo +git -C ~/git/libbpfgo checkout v0.9.2-libbpf-1.5.1 +git -C ~/git/libbpfgo submodule update --init --recursive +make -C ~/git/libbpfgo libbpfgo-static +go install github.com/magefile/mage@latest + +# 7) Generate against the live kernel (the syscall-coverage audit is +# kernel-specific; IOR_FORCE_GENERATE skips the strict diff against the +# committed audit which was generated on a different kernel build). +cd ~/git/ior +env IOR_FORCE_GENERATE=1 GOTOOLCHAIN=auto mage generate +env GOTOOLCHAIN=auto mage all + +# 8) Smoke test. +sudo ./ior -plain -duration 5 +``` + +If `./ior -plain -duration 5` prints `Probing for 5s` and a stream of CSV rows, the +install is good. If it instead prints `permission denied` on tracepoint attach, you +are still on the stock RHEL kernel — verify with `uname -r` and check +`grubby --default-kernel`. + ## TUI Flamegraphs Flamegraphs are available only inside the TUI dashboard. -- cgit v1.2.3