summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-01 10:02:19 +0300
committerPaul Buetow <paul@buetow.org>2026-05-01 10:02:19 +0300
commit0dc3dc4e0c8367bc8399d3987251015a0e135fd9 (patch)
tree5ac7a5455cd9f89c70df429704e97424ffd8baa9
parent92a36a8c5f23756b8c6d721e89450752409ddd75 (diff)
update instructions
-rw-r--r--AGENTS.md7
-rw-r--r--Magefile.go162
-rw-r--r--README.md94
3 files changed, 263 insertions, 0 deletions
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:
+
+<img src=demo/assets/01-launch.gif width=720 alt="Cold start: PID picker, then the dashboard appears" />
+
+<img src=demo/assets/13-tui-flamegraph.gif width=720 alt="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.