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:
@@ -0,0 +1 @@
|
|||||||
|
/bin/
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# benvin-utils — a collection of small tools. Each tool is a directory
|
||||||
|
# containing a `package main`; the shared library lives under common/.
|
||||||
|
#
|
||||||
|
# Tools are auto-discovered, so adding a new tool directory needs no Makefile
|
||||||
|
# edits: `make all`, `make install`, and `make <tool>` just pick it up.
|
||||||
|
|
||||||
|
GO ?= go
|
||||||
|
BINDIR ?= bin
|
||||||
|
# GOBIN, else $(go env GOPATH)/bin — where `make install` puts binaries.
|
||||||
|
GOBIN ?= $(shell $(GO) env GOBIN)
|
||||||
|
ifeq ($(GOBIN),)
|
||||||
|
GOBIN := $(shell $(GO) env GOPATH)/bin
|
||||||
|
endif
|
||||||
|
|
||||||
|
# A directory is a "tool" if it holds at least one *.go file with `package main`
|
||||||
|
# and is not the shared library or hidden/dot dir.
|
||||||
|
TOOLS := $(sort $(patsubst %/,%,$(dir $(shell grep -rl --include='*.go' '^package main' . 2>/dev/null))))
|
||||||
|
TOOLS := $(filter-out ./common%,$(TOOLS))
|
||||||
|
TOOLS := $(patsubst ./%,%,$(TOOLS))
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := all
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
|
all: $(TOOLS) ## Build every tool into ./bin
|
||||||
|
|
||||||
|
# Pattern rule: `make <tool>` builds that one tool into ./bin/<tool>.
|
||||||
|
.PHONY: $(TOOLS)
|
||||||
|
$(TOOLS): %: | $(BINDIR)
|
||||||
|
@echo " build $@"
|
||||||
|
@$(GO) build -o $(BINDIR)/$@ ./$@
|
||||||
|
|
||||||
|
$(BINDIR):
|
||||||
|
@mkdir -p $(BINDIR)
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: ## Install every tool into $(GOBIN)
|
||||||
|
@echo " install → $(GOBIN)"
|
||||||
|
@$(GO) install ./...
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run all tests
|
||||||
|
@$(GO) test ./...
|
||||||
|
|
||||||
|
.PHONY: tidy
|
||||||
|
tidy: ## go mod tidy
|
||||||
|
@$(GO) mod tidy
|
||||||
|
|
||||||
|
.PHONY: fmt vet
|
||||||
|
fmt: ; @$(GO) fmt ./...
|
||||||
|
vet: ; @$(GO) vet ./...
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: ## Remove built binaries
|
||||||
|
@rm -rf $(BINDIR)
|
||||||
|
|
||||||
|
.PHONY: list
|
||||||
|
list: ## List discovered tools
|
||||||
|
@echo $(TOOLS)
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help
|
||||||
|
@echo "benvin-utils — tools: $(TOOLS)"
|
||||||
|
@echo
|
||||||
|
@grep -hE '^[a-zA-Z_$$()%-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||||
|
sort | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||||
|
@echo
|
||||||
|
@echo " make <tool> Build one tool (e.g. make podgap)"
|
||||||
@@ -1,3 +1,44 @@
|
|||||||
# benvin-utils
|
# benvin-utils
|
||||||
|
|
||||||
Monorepo of small single-purpose CLI tools sharing a common library
|
A monorepo of small, single-purpose command-line tools. Each tool is its own
|
||||||
|
directory (a `package main`); shared code lives under [`common/`](./common) so
|
||||||
|
tools reuse the same TUI primitives and Kubernetes client bootstrapping instead
|
||||||
|
of copy-pasting them.
|
||||||
|
|
||||||
|
```
|
||||||
|
benvin-utils/
|
||||||
|
├── common/ # shared library, imported by tools
|
||||||
|
│ ├── tui/ # direction markers, style palette, padding/layout helpers
|
||||||
|
│ └── kube/ # in-cluster→kubeconfig client loading
|
||||||
|
├── podgap/ # tool: pod requests/limits vs actual usage (live TUI)
|
||||||
|
└── Makefile # auto-discovers tools — no edits when you add one
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | What it does |
|
||||||
|
|------|--------------|
|
||||||
|
| [`podgap`](./podgap) | Live TUI comparing pod CPU/memory **requests & limits vs actual usage**, with ▲/▼ change markers. |
|
||||||
|
|
||||||
|
## Build & install
|
||||||
|
|
||||||
|
Tools are auto-discovered (any directory with a `package main`), so the targets
|
||||||
|
below need no editing when you add a new tool.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make all # build every tool into ./bin
|
||||||
|
make podgap # build just one tool into ./bin/podgap
|
||||||
|
make install # go install every tool into $GOBIN (or $GOPATH/bin)
|
||||||
|
make test # run all tests
|
||||||
|
make list # list discovered tools
|
||||||
|
make help # show all targets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new tool
|
||||||
|
|
||||||
|
1. `mkdir mytool && $EDITOR mytool/main.go` with `package main`.
|
||||||
|
2. Import shared helpers as needed:
|
||||||
|
`git.unkin.net/unkin/benvin-utils/common/tui`,
|
||||||
|
`git.unkin.net/unkin/benvin-utils/common/kube`.
|
||||||
|
3. That's it — `make mytool`, `make all`, and `make install` pick it up
|
||||||
|
automatically.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Package kube provides shared Kubernetes client bootstrapping for the tools in
|
||||||
|
// benvin-utils: a single loader that works both in-cluster and from a local
|
||||||
|
// kubeconfig, plus a helper that returns the core and metrics clients together.
|
||||||
|
package kube
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadConfig resolves a *rest.Config, preferring in-cluster credentials, then
|
||||||
|
// the explicit path, then $KUBECONFIG, then ~/.kube/config.
|
||||||
|
func LoadConfig(explicit string) (*rest.Config, error) {
|
||||||
|
if cfg, err := rest.InClusterConfig(); err == nil {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
path := explicit
|
||||||
|
if path == "" {
|
||||||
|
path = os.Getenv("KUBECONFIG")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
path = filepath.Join(home, ".kube", "config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg, err := clientcmd.BuildConfigFromFlags("", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load kubeconfig %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients returns the core and metrics clientsets for the resolved config.
|
||||||
|
func Clients(explicit string) (kubernetes.Interface, metricsv.Interface, error) {
|
||||||
|
cfg, err := LoadConfig(explicit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
core, err := kubernetes.NewForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("core client: %w", err)
|
||||||
|
}
|
||||||
|
mc, err := metricsv.NewForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("metrics client: %w", err)
|
||||||
|
}
|
||||||
|
return core, mc, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package tui holds small, reusable terminal-UI primitives shared across the
|
||||||
|
// tools in benvin-utils: direction markers, a common style palette, and
|
||||||
|
// width-aware padding/truncation helpers that respect ANSI styling.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared style palette. Tools should prefer these so every benvin-utils TUI
|
||||||
|
// looks consistent.
|
||||||
|
var (
|
||||||
|
Header = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("240"))
|
||||||
|
Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("87"))
|
||||||
|
Dim = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||||
|
Up = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // a rising value = red-ish
|
||||||
|
Down = lipgloss.NewStyle().Foreground(lipgloss.Color("84")) // a falling value = green
|
||||||
|
Hot = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // over threshold
|
||||||
|
Warn = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // approaching threshold
|
||||||
|
Err = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("203"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Marker glyphs for direction-of-change indicators.
|
||||||
|
const (
|
||||||
|
GlyphUp = "▲"
|
||||||
|
GlyphDown = "▼"
|
||||||
|
GlyphFlat = "·"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Arrow renders a direction marker: 1 = up (▲), -1 = down (▼), 0 = flat (·).
|
||||||
|
func Arrow(dir int) string {
|
||||||
|
switch {
|
||||||
|
case dir > 0:
|
||||||
|
return Up.Render(GlyphUp)
|
||||||
|
case dir < 0:
|
||||||
|
return Down.Render(GlyphDown)
|
||||||
|
default:
|
||||||
|
return Dim.Render(GlyphFlat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign reports whether d is positive (1), negative (-1), or zero (0). It is the
|
||||||
|
// usual companion to Arrow when comparing a new sample against a previous one.
|
||||||
|
func Sign[T int | int64 | float64](d T) int {
|
||||||
|
switch {
|
||||||
|
case d > 0:
|
||||||
|
return 1
|
||||||
|
case d < 0:
|
||||||
|
return -1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad right-pads plain (unstyled) text to width w, truncating if longer.
|
||||||
|
func Pad(s string, w int) string {
|
||||||
|
if len(s) > w {
|
||||||
|
s = s[:w]
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", w-len(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PadVisible right-pads a possibly ANSI-styled cell to width w using its visible
|
||||||
|
// (rendered) width, so styled cells still align in a table.
|
||||||
|
func PadVisible(s string, w int) string {
|
||||||
|
if vis := lipgloss.Width(s); vis < w {
|
||||||
|
return s + strings.Repeat(" ", w-vis)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate shortens s to at most n runes, adding an ellipsis when it cuts.
|
||||||
|
func Truncate(s string, n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
module git.unkin.net/unkin/benvin-utils
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/spf13/pflag v1.0.10
|
||||||
|
k8s.io/api v0.34.2
|
||||||
|
k8s.io/apimachinery v0.34.2
|
||||||
|
k8s.io/client-go v0.34.2
|
||||||
|
k8s.io/metrics v0.34.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/net v0.38.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.27.0 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/term v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
golang.org/x/time v0.9.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||||
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||||
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
|
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||||
|
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||||
|
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||||
|
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||||
|
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
||||||
|
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
||||||
|
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
||||||
|
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
|
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
||||||
|
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
|
||||||
|
k8s.io/metrics v0.34.2 h1:zao91FNDVPRGIiHLO2vqqe21zZVPien1goyzn0hsz90=
|
||||||
|
k8s.io/metrics v0.34.2/go.mod h1:Ydulln+8uZZctUM8yrUQX4rfq/Ay6UzsuXf24QJ37Vc=
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||||
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user