Files
Ben Vin 016f93ab4f 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
2026-07-12 22:04:29 +10:00

187 lines
5.1 KiB
Go

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)
}
}