Skip to content

Commit 87a21a4

Browse files
authored
Merge pull request #16 from mayur-tolexo/feat/usage-distribution-explain
feat(scan): redesigned, trustworthy scan UX — cards, full distribution, OOM-safe memory, concurrency
2 parents c795252 + a6460a4 commit 87a21a4

31 files changed

Lines changed: 927 additions & 290 deletions

CHANGELOG.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,33 @@ All notable changes to kubetidy are documented here. The format follows
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) (pre-1.0: minor = features, patch =
66
fixes/UX).
77

8-
## [Unreleased] — v0.1.1
8+
## [Unreleased] — v0.1.2
9+
10+
Scan output rebuilt around making every recommendation understandable, plus a real memory
11+
safety fix. Driven by real-cluster feedback.
12+
13+
### Added
14+
- **Card per recommendation.** Each finding is its own block: a bordered table of the observed
15+
**avg / p95 / p99 / peak** for CPU and memory beside the `request → proposed` change, with the
16+
workload name on its own line (no more truncated/again-wrapped wide table).
17+
- **Confidence score % inline** next to the band (e.g. `▒ low 34%`), so you can watch it climb
18+
as history accumulates.
19+
- Data **provenance in the banner** (`5h history, ~202 samples/workload`) and a per-card `basis`
20+
line, so "what was measured, over how long, from how many samples" is always visible.
21+
- `--explain` gains a "why this recommendation" block: requested vs the full observed
22+
distribution vs proposed, with an over-allocation verdict.
23+
24+
### Changed
25+
- **OOM-safe memory sizing.** Memory is the dangerous resource and a short window can miss the
26+
true peak, so the memory headroom now scales inversely with data maturity — a young history
27+
keeps a large cushion (up to peak × ~2), tightening to peak + 15% only once a representative
28+
window of samples backs it. CPU stays lean (under-sizing it only throttles). New
29+
`Policy.MemoryImmatureSafety`.
30+
- `model.Percentiles` gains `Avg` + `P99`; providers populate them (Prometheus `avg_over_time` +
31+
`quantile_over_time(0.99)`; operator histogram `Mean()` + p99; `UsageProfile` CRD
32+
`MetricHistory` gains additive `avg`/`p99` — operator redeploy needed to populate).
33+
34+
## [0.1.1] — 2026-06-01
935

1036
The operator's Tier‑0 history now actually reaches a scan, and confidence is honest about how
1137
much data backs each recommendation. Found and fixed against a real cluster.
@@ -48,5 +74,6 @@ Initial public release.
4874
- Install via **krew**, `curl | sh`, or pre-built archives; tagged GitHub release pipeline
4975
(GoReleaser) with an auto-rendered krew manifest.
5076

51-
[Unreleased]: https://github.com/mayur-tolexo/kubetidy/compare/v0.1.0...HEAD
77+
[Unreleased]: https://github.com/mayur-tolexo/kubetidy/compare/v0.1.1...HEAD
78+
[0.1.1]: https://github.com/mayur-tolexo/kubetidy/releases/tag/v0.1.1
5279
[0.1.0]: https://github.com/mayur-tolexo/kubetidy/releases/tag/v0.1.0

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,20 @@ kubectl tidy pr --include-grow # also include under-provisioned ("grow") w
279279
## Rightsizing policy (defaults)
280280

281281
- **CPU request** = P95 + 15% headroom; **no CPU limit** by default (avoids throttling).
282-
- **Memory request** = max + 15% headroom (memory OOMs, so we use max, not a percentile);
283-
**memory limit** = request (Guaranteed QoS).
282+
Under-sizing CPU only throttles, so it stays lean.
283+
- **Memory request** = peak + headroom, with **OOM safety**: memory is the dangerous resource
284+
(under-sizing kills the pod), and a short window may not have seen the true peak. So the
285+
memory buffer **scales with data maturity** — a young history keeps a large cushion
286+
(e.g. peak × 2), tightening to peak + 15% only once a representative window of samples backs
287+
it. **memory limit** = request (Guaranteed QoS).
284288
- **Snapshot safety**: when only a single metrics-server snapshot is available, an extra
285289
buffer and request floors keep recommendations conservative.
286290

287-
All defaults are surfaced in `--explain` and overridable. The number is never a black box.
291+
Every scan shows its work: each recommendation is a card with the observed **avg / p95 / p99 /
292+
peak** for CPU and memory beside the `request → proposed` change, the **window + sample count**
293+
the numbers rest on, and a **confidence** grade (`▒ low · ▓ med · █ high` + score%) that grows
294+
as history accumulates. `--explain <workload>` adds the full derivation. The number is never a
295+
black box.
288296

289297
## Status
290298

api/v1alpha1/usageprofile_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ type TargetRef struct {
1515
// MetricHistory is the recorded usage for one metric (CPU or memory) of one container: the
1616
// summary percentiles plus the encoded decaying-histogram state used to rehydrate exactly.
1717
type MetricHistory struct {
18+
// Avg is the mean observed value (CPU in millicores, memory in bytes).
19+
// +optional
20+
Avg float64 `json:"avg,omitempty"`
1821
// P50 is the median observed value (CPU in millicores, memory in bytes).
1922
P50 float64 `json:"p50"`
2023
// P95 is the 95th-percentile observed value.
2124
P95 float64 `json:"p95"`
25+
// P99 is the 99th-percentile observed value.
26+
// +optional
27+
P99 float64 `json:"p99,omitempty"`
2228
// Max is the largest observed value.
2329
Max float64 `json:"max"`
2430
// Histogram is the base64-encoded decaying-histogram snapshot, so the operator can resume

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/kubetidy/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@ package main
66
import (
77
"context"
88
"fmt"
9+
"io"
910
"os"
1011
"os/signal"
1112
"syscall"
1213

14+
"k8s.io/klog/v2"
15+
1316
"github.com/kubetidy/kubetidy/internal/cli"
1417
)
1518

1619
func main() {
20+
// client-go logs through klog (e.g. rate-limit warnings). This is a clean end-user CLI, so
21+
// keep those internal logs out of the output entirely.
22+
klog.LogToStderr(false)
23+
klog.SetOutput(io.Discard)
24+
1725
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
1826
defer stop()
1927

config/crd/kubetidy.io_usageprofiles.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ spec:
8686
cpu:
8787
description: CPU is the CPU usage history (millicores).
8888
properties:
89+
avg:
90+
description: Avg is the mean observed value (CPU in millicores,
91+
memory in bytes).
92+
type: number
8993
histogram:
9094
description: |-
9195
Histogram is the base64-encoded decaying-histogram snapshot, so the operator can resume
@@ -101,6 +105,9 @@ spec:
101105
p95:
102106
description: P95 is the 95th-percentile observed value.
103107
type: number
108+
p99:
109+
description: P99 is the 99th-percentile observed value.
110+
type: number
104111
required:
105112
- max
106113
- p50
@@ -109,6 +116,10 @@ spec:
109116
memory:
110117
description: Memory is the memory usage history (bytes).
111118
properties:
119+
avg:
120+
description: Avg is the mean observed value (CPU in millicores,
121+
memory in bytes).
122+
type: number
112123
histogram:
113124
description: |-
114125
Histogram is the base64-encoded decaying-histogram snapshot, so the operator can resume
@@ -124,6 +135,9 @@ spec:
124135
p95:
125136
description: P95 is the 95th-percentile observed value.
126137
type: number
138+
p99:
139+
description: P99 is the 99th-percentile observed value.
140+
type: number
127141
required:
128142
- max
129143
- p50

docs/assets/demo.gif

-74.7 KB
Loading

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
k8s.io/api v0.36.0
1111
k8s.io/apimachinery v0.36.0
1212
k8s.io/client-go v0.36.0
13+
k8s.io/klog/v2 v2.140.0
1314
k8s.io/metrics v0.32.0
1415
)
1516

@@ -46,7 +47,6 @@ require (
4647
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
4748
gopkg.in/inf.v0 v0.9.1 // indirect
4849
gopkg.in/yaml.v3 v3.0.1 // indirect
49-
k8s.io/klog/v2 v2.140.0 // indirect
5050
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
5151
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
5252
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect

hack/demo/demo.tape

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
# VHS tape for the kubetidy README demo GIF.
22
# Regenerate with: make demo-gif (needs a running kind demo + Prometheus; see the target)
33
#
4-
# Records a real `kubectl tidy scan` + `kubectl tidy diff` at Tier 1 (Prometheus) against the
5-
# kind demo namespace (the deliberately over-provisioned workloads in
6-
# hack/kind/demo-workloads.yaml). Tier 1 gives stable, high-confidence numbers and no caveat
7-
# banner, so the recording is reproducible.
8-
#
9-
# Prereqs the target sets up: kind demo running, Prometheus deployed, and a port-forward of
10-
# prometheus-server to localhost:9090.
4+
# Records a real `kubectl tidy scan` at Tier 1 (Prometheus) against the kind demo namespace
5+
# (the over-provisioned load generators in hack/kind/demo-workloads.yaml), showing the card
6+
# layout: per-workload usage distribution (avg/p95/p99/peak) and the request → proposed change.
117
#
128
# Docs: https://github.com/charmbracelet/vhs
139

@@ -16,13 +12,13 @@ Output docs/assets/demo.gif
1612
Require kubectl
1713

1814
Set Shell bash
19-
Set FontSize 16
20-
Set Width 1280
21-
Set Height 820
22-
Set Padding 24
15+
Set FontSize 15
16+
Set Width 1240
17+
Set Height 1000
18+
Set Padding 22
2319
Set Margin 0
2420
Set Theme "Dracula"
25-
Set TypingSpeed 35ms
21+
Set TypingSpeed 32ms
2622

2723
# Setup (hidden): clean prompt, our binary on PATH, isolated kubeconfig for the kind cluster.
2824
Hide
@@ -32,7 +28,4 @@ Show
3228

3329
Sleep 700ms
3430
Type "kubectl tidy scan -n kubetidy-demo --prometheus-url http://localhost:9090 --window 1h" Sleep 700ms Enter
35-
Sleep 4500ms
36-
37-
Type "kubectl tidy diff -n kubetidy-demo --prometheus-url http://localhost:9090 --window 1h" Sleep 700ms Enter
38-
Sleep 5500ms
31+
Sleep 7s

hack/kind/demo-workloads.yaml

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# Demo workloads for kubetidy local testing.
22
#
3-
# These are deliberately OVER-PROVISIONED: each asks for far more CPU/memory than it uses
4-
# (the pause image uses almost nothing), so `kubectl tidy scan` surfaces real rightsizing
5-
# savings and a low efficiency score.
3+
# These are deliberately OVER-PROVISIONED: each REQUESTS far more CPU/memory than it uses, so
4+
# `kubectl tidy scan` surfaces real rightsizing savings. Unlike a bare `pause` container (which
5+
# uses ~0 and shows up as "—"), these tiny Python load generators hold a fixed chunk of memory
6+
# and burn a small, steady amount of CPU — so the scan shows a real avg/p95/p99/peak
7+
# distribution and a meaningful reduction.
68
#
7-
# Requests are sized to fit on a single default kind node (12 CPU / ~8Gi): the whole namespace
8-
# asks for ~5 cores / ~5Gi, so every pod schedules and the rollout settles.
9+
# Requests are sized to fit a single default kind node (12 CPU / ~8Gi); actual usage is a small
10+
# fraction of the request.
911
apiVersion: v1
1012
kind: Namespace
1113
metadata:
@@ -19,23 +21,28 @@ metadata:
1921
spec:
2022
replicas: 2
2123
selector:
22-
matchLabels:
23-
app: checkout-api
24+
matchLabels: { app: checkout-api }
2425
template:
2526
metadata:
26-
labels:
27-
app: checkout-api
27+
labels: { app: checkout-api }
2828
spec:
2929
containers:
3030
- name: checkout-api
31-
image: registry.k8s.io/pause:3.10
31+
image: python:3.12-alpine
32+
# Hold ~120Mi and burn a small, steady CPU slice (busy burst + sleep).
33+
command: ["python3", "-c"]
34+
args:
35+
- |
36+
import time
37+
buf = bytearray(120 * 1024 * 1024) # hold ~120Mi
38+
while True: # ~12% of a core
39+
t = time.time()
40+
while time.time() - t < 0.02:
41+
pass
42+
time.sleep(0.15)
3243
resources:
33-
requests:
34-
cpu: "1000m" # asks for a full core...
35-
memory: "1Gi" # ...and 1Gi, but pause uses almost nothing
36-
limits:
37-
cpu: "1000m"
38-
memory: "1Gi"
44+
requests: { cpu: "1000m", memory: "1Gi" } # asks for a full core + 1Gi...
45+
limits: { cpu: "1000m", memory: "1Gi" } # ...but uses ~0.1 core / ~120Mi
3946
---
4047
apiVersion: apps/v1
4148
kind: Deployment
@@ -45,23 +52,27 @@ metadata:
4552
spec:
4653
replicas: 1
4754
selector:
48-
matchLabels:
49-
app: search-indexer
55+
matchLabels: { app: search-indexer }
5056
template:
5157
metadata:
52-
labels:
53-
app: search-indexer
58+
labels: { app: search-indexer }
5459
spec:
5560
containers:
5661
- name: search-indexer
57-
image: registry.k8s.io/pause:3.10
62+
image: python:3.12-alpine
63+
command: ["python3", "-c"]
64+
args:
65+
- |
66+
import time
67+
buf = bytearray(220 * 1024 * 1024) # hold ~220Mi
68+
while True: # ~18% of a core
69+
t = time.time()
70+
while time.time() - t < 0.03:
71+
pass
72+
time.sleep(0.14)
5873
resources:
59-
requests:
60-
cpu: "2000m"
61-
memory: "2Gi"
62-
limits:
63-
cpu: "2000m"
64-
memory: "2Gi"
74+
requests: { cpu: "2000m", memory: "2Gi" }
75+
limits: { cpu: "2000m", memory: "2Gi" }
6576
---
6677
apiVersion: apps/v1
6778
kind: StatefulSet
@@ -72,20 +83,24 @@ spec:
7283
serviceName: cache
7384
replicas: 2
7485
selector:
75-
matchLabels:
76-
app: cache
86+
matchLabels: { app: cache }
7787
template:
7888
metadata:
79-
labels:
80-
app: cache
89+
labels: { app: cache }
8190
spec:
8291
containers:
8392
- name: cache
84-
image: registry.k8s.io/pause:3.10
93+
image: python:3.12-alpine
94+
command: ["python3", "-c"]
95+
args:
96+
- |
97+
import time
98+
buf = bytearray(80 * 1024 * 1024) # hold ~80Mi
99+
while True: # ~8% of a core
100+
t = time.time()
101+
while time.time() - t < 0.015:
102+
pass
103+
time.sleep(0.18)
85104
resources:
86-
requests:
87-
cpu: "500m"
88-
memory: "512Mi"
89-
limits:
90-
cpu: "500m"
91-
memory: "512Mi"
105+
requests: { cpu: "500m", memory: "512Mi" }
106+
limits: { cpu: "500m", memory: "512Mi" }

0 commit comments

Comments
 (0)