Initial benvin-utils monorepo with podgap tool

Add a monorepo for small single-purpose CLI tools that share a common
library, so tools reuse TUI primitives and Kubernetes client bootstrapping
instead of duplicating them.

- common/tui: direction markers, style palette, width-aware padding/layout
- common/kube: in-cluster→kubeconfig client loading + core/metrics clients
- podgap: live TUI comparing pod requests/limits vs actual usage, with
  up/down markers showing per-pod change since the last refresh
- Makefile auto-discovers tool directories: make <tool> | all | install
This commit is contained in:
Ben Vin
2026-07-12 21:42:14 +10:00
committed by Ben Vincent
parent e6bffb35ac
commit 016f93ab4f
12 changed files with 1221 additions and 1 deletions
+74
View File
@@ -0,0 +1,74 @@
# podgap
Shows the **gap** between what Kubernetes pods reserve (requests/limits) and what
they actually use — live, in a terminal UI that refreshes in place and marks
whether each pod's usage rose (▲) or fell (▼) since the last refresh. Use it to
find over-provisioned pods (reserving cores they never touch) and
under-provisioned ones (running hot against their limits).
Usage is summed across all containers in a pod; only `Running` pods are shown.
Reads live data from the `metrics.k8s.io` API (same source as `kubectl top`).
## Build
```sh
make podgap # from the repo root → ./bin/podgap
# or: go build -o podgap ./podgap
```
## Usage
```sh
podgap # live TUI, all namespaces, refresh every 3s
podgap --top 15 -i 5s # top 15 by CPU waste, refresh every 5s
podgap -n observability -s mem-util
podgap --over # over-provisioned pods (right-size DOWN candidates)
podgap --under # usage over request or near limit (right-size UP)
podgap -o json # one-shot machine output (no TUI); also -o csv
```
### Keys
| Key | Action |
|-----|--------|
| `↑`/`↓`, `j`/`k` | Scroll |
| `pgup`/`pgdn`, `g`/`G` | Page / jump to top/bottom |
| `c` / `C` | Sort by CPU waste / CPU utilization |
| `m` / `M` | Sort by memory waste / memory utilization |
| `n` | Sort by name |
| `p` / `space` | Pause / resume auto-refresh |
| `r` | Refresh now |
| `q` / `esc` | Quit |
### Reading the table
```
NAMESPACE POD CPU req→use/lim ▲ CPU% MEM req→use/lim ▲ MEM% CPU wst MEM wst
```
- **req→use/lim** — configured request, live usage, configured limit (`-` if unset).
- **▲ / ▼ / ·** — usage rose / fell / held steady since the last refresh.
- **CPU% / MEM%** — usage as a percentage of the *request*; tinted amber ≥80%,
red ≥100% (`n/a` if no request set).
- **wst** (waste) — `request usage`. Positive = idle reserved capacity;
negative = over the request (a right-size-*up* candidate).
## Flags
| Flag | Default | Description |
|------|---------|-------------|
| `-n, --namespace` | all | Namespace to inspect |
| `-s, --sort` | `cpu-waste` | `cpu-waste`, `mem-waste`, `cpu-util`, `mem-util`, `cpu-use`, `mem-use`, `name` |
| `--top` | `0` (all) | Show only the top N rows |
| `-i, --interval` | `3s` | TUI refresh interval |
| `-o, --output` | `tui` | `tui`, `json`, `csv` |
| `--over` | off | Only over-provisioned pods (util < `--threshold`) |
| `--under` | off | Only under-provisioned pods |
| `--threshold` | `0.5` | Utilization fraction defining "over-provisioned" |
| `--kubeconfig` | — | Path to kubeconfig (default: in-cluster, then `$KUBECONFIG`, then `~/.kube/config`) |
## Requirements
- A cluster with metrics-server / the `metrics.k8s.io/v1beta1` API (check with
`kubectl top pods`).
- RBAC: `list` on `pods` and on `pods.metrics.k8s.io`.
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
)
// row is one pod's aggregated (summed across containers) request/limit/usage.
type row struct {
Namespace string `json:"namespace"`
Pod string `json:"pod"`
CPUReqM int64 `json:"cpuRequestMillicores"`
CPULimM int64 `json:"cpuLimitMillicores"`
CPUUseM int64 `json:"cpuUsageMillicores"`
MemReqB int64 `json:"memRequestBytes"`
MemLimB int64 `json:"memLimitBytes"`
MemUseB int64 `json:"memUsageBytes"`
}
func (r row) cpuUtil() float64 {
if r.CPUReqM == 0 {
return 0
}
return float64(r.CPUUseM) / float64(r.CPUReqM)
}
func (r row) memUtil() float64 {
if r.MemReqB == 0 {
return 0
}
return float64(r.MemUseB) / float64(r.MemReqB)
}
// cpuWasteM / memWasteB are reserved-but-unused amounts (negative = over request).
func (r row) cpuWasteM() int64 { return r.CPUReqM - r.CPUUseM }
func (r row) memWasteB() int64 { return r.MemReqB - r.MemUseB }
// collect lists pod specs and pod metrics and joins them by namespace/name.
func collect(ctx context.Context, core kubernetes.Interface, mc metricsv.Interface, ns string) ([]row, error) {
pods, err := core.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("list pods: %w", err)
}
metrics, err := mc.MetricsV1beta1().PodMetricses(ns).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("list pod metrics: %w", err)
}
type usage struct{ cpuM, memB int64 }
used := make(map[string]usage, len(metrics.Items))
for _, pm := range metrics.Items {
var u usage
for _, c := range pm.Containers {
u.cpuM += c.Usage.Cpu().MilliValue()
u.memB += c.Usage.Memory().Value()
}
used[pm.Namespace+"/"+pm.Name] = u
}
rows := make([]row, 0, len(pods.Items))
for _, p := range pods.Items {
// Only running pods have meaningful live usage to compare against.
if p.Status.Phase != corev1.PodRunning {
continue
}
r := row{Namespace: p.Namespace, Pod: p.Name}
for _, c := range p.Spec.Containers {
r.CPUReqM += c.Resources.Requests.Cpu().MilliValue()
r.CPULimM += c.Resources.Limits.Cpu().MilliValue()
r.MemReqB += c.Resources.Requests.Memory().Value()
r.MemLimB += c.Resources.Limits.Memory().Value()
}
if u, ok := used[p.Namespace+"/"+p.Name]; ok {
r.CPUUseM = u.cpuM
r.MemUseB = u.memB
}
rows = append(rows, r)
}
return rows, nil
}
func filterRows(rows []row, opts options) []row {
if !opts.overOnly && !opts.underOnly {
return rows
}
out := rows[:0]
for _, r := range rows {
if opts.overOnly {
overCPU := r.CPUReqM > 0 && r.cpuUtil() < opts.threshold
overMem := r.MemReqB > 0 && r.memUtil() < opts.threshold
if overCPU || overMem {
out = append(out, r)
continue
}
}
if opts.underOnly {
underCPU := r.CPUReqM > 0 && r.CPUUseM > r.CPUReqM
underMem := r.MemReqB > 0 && r.MemUseB > r.MemReqB
overLimCPU := r.CPULimM > 0 && r.CPUUseM > r.CPULimM*9/10
overLimMem := r.MemLimB > 0 && r.MemUseB > r.MemLimB*9/10
if underCPU || underMem || overLimCPU || overLimMem {
out = append(out, r)
}
}
}
return out
}
func sortRows(rows []row, key string) {
less := map[string]func(a, b row) bool{
"cpu-waste": func(a, b row) bool { return a.cpuWasteM() > b.cpuWasteM() },
"mem-waste": func(a, b row) bool { return a.memWasteB() > b.memWasteB() },
"cpu-util": func(a, b row) bool { return a.cpuUtil() > b.cpuUtil() },
"mem-util": func(a, b row) bool { return a.memUtil() > b.memUtil() },
"cpu-use": func(a, b row) bool { return a.CPUUseM > b.CPUUseM },
"mem-use": func(a, b row) bool { return a.MemUseB > b.MemUseB },
"name": func(a, b row) bool { return a.Namespace+a.Pod < b.Namespace+b.Pod },
}
fn, ok := less[key]
if !ok {
fn = less["cpu-waste"]
}
sort.SliceStable(rows, func(i, j int) bool { return fn(rows[i], rows[j]) })
}
func render(w io.Writer, rows []row, format string) error {
switch format {
case "json":
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(rows)
case "csv":
fmt.Fprintln(w, "namespace,pod,cpu_req_m,cpu_use_m,cpu_lim_m,cpu_util,mem_req_bytes,mem_use_bytes,mem_lim_bytes,mem_util")
for _, r := range rows {
fmt.Fprintf(w, "%s,%s,%d,%d,%d,%.3f,%d,%d,%d,%.3f\n",
r.Namespace, r.Pod, r.CPUReqM, r.CPUUseM, r.CPULimM, r.cpuUtil(),
r.MemReqB, r.MemUseB, r.MemLimB, r.memUtil())
}
return nil
}
return fmt.Errorf("unknown output format %q", format)
}
// --- unit formatting ---
func cpu(m int64) string {
if m == 0 {
return "-"
}
if m < 0 {
return "-" + cpu(-m)
}
if m < 1000 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%.2f", float64(m)/1000)
}
func mem(b int64) string {
if b == 0 {
return "-"
}
if b < 0 {
return "-" + mem(-b)
}
const ki = 1024
switch {
case b >= ki*ki*ki:
return fmt.Sprintf("%.1fGi", float64(b)/(ki*ki*ki))
case b >= ki*ki:
return fmt.Sprintf("%.0fMi", float64(b)/(ki*ki))
case b >= ki:
return fmt.Sprintf("%.0fKi", float64(b)/ki)
default:
return fmt.Sprintf("%dB", b)
}
}
+73
View File
@@ -0,0 +1,73 @@
// Command podgap shows the gap between what Kubernetes pods reserve
// (requests/limits) and what they actually use, live in a terminal UI.
//
// The default mode opens an interactive TUI that refreshes in place and marks
// whether each pod's usage rose or fell since the last refresh. The json/csv
// output modes are one-shot dumps for scripting.
package main
import (
"context"
"fmt"
"os"
"time"
"git.unkin.net/unkin/benvin-utils/common/kube"
"github.com/spf13/pflag"
)
type options struct {
namespace string
sortKey string
top int
interval time.Duration
output string
kubeconfig string
overOnly bool
underOnly bool
threshold float64
}
func main() {
opts := options{}
pflag.StringVarP(&opts.namespace, "namespace", "n", "", "namespace to inspect (default: all namespaces)")
pflag.StringVarP(&opts.sortKey, "sort", "s", "cpu-waste", "sort by: cpu-waste, mem-waste, cpu-util, mem-util, cpu-use, mem-use, name")
pflag.IntVar(&opts.top, "top", 0, "show only the top N rows after sorting (0 = all)")
pflag.DurationVarP(&opts.interval, "interval", "i", 0, "TUI refresh interval (e.g. 5s); 0 = default 3s")
pflag.StringVarP(&opts.output, "output", "o", "tui", "output: tui (interactive), json, csv")
pflag.StringVar(&opts.kubeconfig, "kubeconfig", "", "path to kubeconfig (default: $KUBECONFIG or ~/.kube/config)")
pflag.BoolVar(&opts.overOnly, "over", false, "show only over-provisioned pods (util below --threshold)")
pflag.BoolVar(&opts.underOnly, "under", false, "show only under-provisioned pods (usage above request or limit)")
pflag.Float64Var(&opts.threshold, "threshold", 0.5, "utilization fraction below which a pod is 'over-provisioned' (with --over)")
pflag.Parse()
if err := run(opts); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(opts options) error {
core, mc, err := kube.Clients(opts.kubeconfig)
if err != nil {
return err
}
// json/csv are one-shot, script-friendly dumps; anything else opens the TUI.
switch opts.output {
case "json", "csv":
rows, err := collect(context.Background(), core, mc, opts.namespace)
if err != nil {
return err
}
rows = filterRows(rows, opts)
sortRows(rows, opts.sortKey)
if opts.top > 0 && len(rows) > opts.top {
rows = rows[:opts.top]
}
return render(os.Stdout, rows, opts.output)
default:
return runTUI(core, mc, opts)
}
}
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"context"
"fmt"
"strings"
"time"
ctui "git.unkin.net/unkin/benvin-utils/common/tui"
tea "github.com/charmbracelet/bubbletea"
"k8s.io/client-go/kubernetes"
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
)
// runTUI launches the interactive, self-refreshing table.
func runTUI(core kubernetes.Interface, mc metricsv.Interface, opts options) error {
if opts.interval <= 0 {
opts.interval = 3 * time.Second
}
m := &model{
core: core,
mc: mc,
opts: opts,
sortKey: opts.sortKey,
prev: map[string]row{},
dirCPU: map[string]int{},
dirMem: map[string]int{},
}
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
}
type model struct {
core kubernetes.Interface
mc metricsv.Interface
opts options
rows []row
prev map[string]row // previous usage per pod, for direction markers
dirCPU map[string]int // -1 down, 0 flat, +1 up (since last refresh)
dirMem map[string]int
sortKey string
offset int
width int
height int
lastUpdate time.Time
paused bool
err error
}
type rowsMsg struct {
rows []row
err error
}
type tickMsg struct{}
func (m *model) Init() tea.Cmd { return tea.Batch(m.refresh(), m.tick()) }
func (m *model) refresh() tea.Cmd {
return func() tea.Msg {
rows, err := collect(context.Background(), m.core, m.mc, m.opts.namespace)
if err != nil {
return rowsMsg{err: err}
}
rows = filterRows(rows, m.opts)
sortRows(rows, m.sortKey)
if m.opts.top > 0 && len(rows) > m.opts.top {
rows = rows[:m.opts.top]
}
return rowsMsg{rows: rows}
}
}
func (m *model) tick() tea.Cmd {
return tea.Tick(m.opts.interval, func(time.Time) tea.Msg { return tickMsg{} })
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tickMsg:
if m.paused {
return m, m.tick()
}
return m, tea.Batch(m.refresh(), m.tick())
case rowsMsg:
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.err = nil
m.computeDirections(msg.rows)
m.rows = msg.rows
m.lastUpdate = time.Now()
m.clampOffset()
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
// computeDirections compares each pod's new usage against the last snapshot.
func (m *model) computeDirections(next []row) {
for _, r := range next {
k := r.Namespace + "/" + r.Pod
if p, ok := m.prev[k]; ok {
m.dirCPU[k] = ctui.Sign(r.CPUUseM - p.CPUUseM)
m.dirMem[k] = ctui.Sign(r.MemUseB - p.MemUseB)
}
m.prev[k] = r
}
}
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "r":
return m, m.refresh()
case "p", " ":
m.paused = !m.paused
case "up", "k":
if m.offset > 0 {
m.offset--
}
case "down", "j":
m.offset++
m.clampOffset()
case "pgup":
m.offset -= m.visibleRows()
m.clampOffset()
case "pgdown":
m.offset += m.visibleRows()
m.clampOffset()
case "g", "home":
m.offset = 0
case "G", "end":
m.offset = len(m.rows)
m.clampOffset()
case "c":
m.reSort("cpu-waste")
case "C":
m.reSort("cpu-util")
case "m":
m.reSort("mem-waste")
case "M":
m.reSort("mem-util")
case "n":
m.reSort("name")
}
return m, nil
}
func (m *model) reSort(key string) {
m.sortKey = key
m.offset = 0
sortRows(m.rows, m.sortKey)
}
func (m *model) visibleRows() int {
if v := m.height - 4; v >= 1 { // title + header + footer + margin
return v
}
return 1
}
func (m *model) clampOffset() {
if max := len(m.rows) - m.visibleRows(); m.offset > max {
m.offset = max
}
if m.offset < 0 {
m.offset = 0
}
}
// column widths (POD absorbs the remaining width)
const (
wNS = 14
wCPU = 20
wPct = 5
wWst = 9
wArr = 1
nGaps = 9 // one space between each of the 10 columns
)
func (m *model) View() string {
if m.width == 0 {
return "starting…"
}
var b strings.Builder
scope := "all namespaces"
if m.opts.namespace != "" {
scope = "ns=" + m.opts.namespace
}
state := "live"
if m.paused {
state = "PAUSED"
}
ts := "—"
if !m.lastUpdate.IsZero() {
ts = m.lastUpdate.Format("15:04:05")
}
title := fmt.Sprintf(" podgap %s sort=%s %s updated %s %d pods ",
scope, m.sortKey, state, ts, len(m.rows))
b.WriteString(ctui.Title.Render(title))
b.WriteString("\n")
if m.err != nil {
b.WriteString(ctui.Err.Render("error: " + m.err.Error()))
b.WriteString("\n")
}
podW := m.podWidth()
header := layout(podW,
"NAMESPACE", "POD",
"CPU req→use/lim", "", "CPU%",
"MEM req→use/lim", "", "MEM%",
"CPU wst", "MEM wst")
b.WriteString(ctui.Header.Render(ctui.PadVisible(header, m.width)))
b.WriteString("\n")
vis := m.visibleRows()
end := m.offset + vis
if end > len(m.rows) {
end = len(m.rows)
}
for i := m.offset; i < end; i++ {
b.WriteString(m.renderRow(m.rows[i], podW))
b.WriteString("\n")
}
for i := end - m.offset; i < vis; i++ {
b.WriteString("\n")
}
b.WriteString(ctui.Dim.Render(m.footer()))
return b.String()
}
func (m *model) footer() string {
more := ""
if len(m.rows) > m.visibleRows() {
more = fmt.Sprintf(" rows %d-%d/%d", m.offset+1, min(m.offset+m.visibleRows(), len(m.rows)), len(m.rows))
}
return " ↑↓ scroll · c/C cpu · m/M mem · n name · p pause · r refresh · q quit" + more +
" ▲ usage up ▼ usage down"
}
func (m *model) podWidth() int {
fixed := wNS + wCPU + wArr + wPct + wCPU + wArr + wPct + wWst + wWst + nGaps
if w := m.width - fixed; w >= 12 {
return w
}
return 12
}
func (m *model) renderRow(r row, podW int) string {
k := r.Namespace + "/" + r.Pod
return layout(podW,
r.Namespace,
ctui.Truncate(r.Pod, podW),
fmt.Sprintf("%s→%s/%s", cpu(r.CPUReqM), cpu(r.CPUUseM), cpu(r.CPULimM)),
ctui.Arrow(m.dirCPU[k]),
colorPct(r.cpuUtil(), r.CPUReqM),
fmt.Sprintf("%s→%s/%s", mem(r.MemReqB), mem(r.MemUseB), mem(r.MemLimB)),
ctui.Arrow(m.dirMem[k]),
colorPct(r.memUtil(), r.MemReqB),
cpu(r.cpuWasteM()),
mem(r.memWasteB()),
)
}
// colorPct formats a utilization percentage and tints it by pressure.
func colorPct(util float64, req int64) string {
if req == 0 {
return ctui.Dim.Render(ctui.Pad("n/a", wPct))
}
s := ctui.Pad(fmt.Sprintf("%.0f%%", util*100), wPct)
switch {
case util >= 1.0:
return ctui.Hot.Render(s)
case util >= 0.8:
return ctui.Warn.Render(s)
default:
return s
}
}
// layout pads each cell to its fixed width (POD uses podW) and joins with spaces.
// Cells may contain ANSI styling, so widths are measured on the visible text.
func layout(podW int, ns, pod, cpuc, cpuArr, cpuPct, memc, memArr, memPct, cpuWst, memWst string) string {
cells := []string{
ctui.Pad(ns, wNS),
ctui.Pad(pod, podW),
ctui.Pad(cpuc, wCPU),
ctui.PadVisible(cpuArr, wArr),
ctui.PadVisible(cpuPct, wPct),
ctui.Pad(memc, wCPU),
ctui.PadVisible(memArr, wArr),
ctui.PadVisible(memPct, wPct),
ctui.Pad(cpuWst, wWst),
ctui.Pad(memWst, wWst),
}
return strings.Join(cells, " ")
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"strings"
"testing"
ctui "git.unkin.net/unkin/benvin-utils/common/tui"
)
func TestComputeDirections(t *testing.T) {
m := &model{prev: map[string]row{}, dirCPU: map[string]int{}, dirMem: map[string]int{}}
// First snapshot: no prior data, so no direction is recorded yet.
m.computeDirections([]row{{Namespace: "ns", Pod: "p", CPUUseM: 100, MemUseB: 1000}})
if got := m.dirCPU["ns/p"]; got != 0 {
t.Fatalf("first snapshot cpu dir = %d, want 0 (unset)", got)
}
// CPU rose, memory fell.
m.computeDirections([]row{{Namespace: "ns", Pod: "p", CPUUseM: 150, MemUseB: 800}})
if got := m.dirCPU["ns/p"]; got != 1 {
t.Errorf("cpu dir after rise = %d, want 1 (▲)", got)
}
if got := m.dirMem["ns/p"]; got != -1 {
t.Errorf("mem dir after drop = %d, want -1 (▼)", got)
}
// No change → flat.
m.computeDirections([]row{{Namespace: "ns", Pod: "p", CPUUseM: 150, MemUseB: 800}})
if got := m.dirCPU["ns/p"]; got != 0 {
t.Errorf("cpu dir after no change = %d, want 0 (·)", got)
}
}
func TestArrowGlyphs(t *testing.T) {
// The shared marker path renders the expected glyph per direction.
cases := []struct {
dir int
want string
}{
{1, ctui.GlyphUp}, {-1, ctui.GlyphDown}, {0, ctui.GlyphFlat},
}
for _, c := range cases {
if got := ctui.Arrow(c.dir); !strings.Contains(got, c.want) {
t.Errorf("Arrow(%d) = %q, want it to contain %q", c.dir, got, c.want)
}
}
}
func TestUnitFormatting(t *testing.T) {
if got := cpu(250); got != "250m" {
t.Errorf("cpu(250) = %q, want 250m", got)
}
if got := cpu(1500); got != "1.50" {
t.Errorf("cpu(1500) = %q, want 1.50", got)
}
if got := cpu(-130); got != "-130m" {
t.Errorf("cpu(-130) = %q, want -130m", got)
}
if got := mem(-134217728); got != "-128Mi" {
t.Errorf("mem(-134217728) = %q, want -128Mi", got)
}
}