Skip to content

Commit 070f136

Browse files
authored
Merge pull request #14 from mayur-tolexo/docs/release-v0.1.1-prep
v0.1.1: honest confidence + bands + docs (re-target to main)
2 parents d9ad5e5 + f3f6eb2 commit 070f136

14 files changed

Lines changed: 325 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Changelog
2+
3+
All notable changes to kubetidy are documented here. The format follows
4+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to follow
5+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) (pre-1.0: minor = features, patch =
6+
fixes/UX).
7+
8+
## [Unreleased] — v0.1.1
9+
10+
The operator's Tier‑0 history now actually reaches a scan, and confidence is honest about how
11+
much data backs each recommendation. Found and fixed against a real cluster.
12+
13+
### Fixed
14+
- **Operator history was silently discarded.** `UsageProfile` declares a status subresource, so
15+
the operator's `Save` (plain `Create`/`Update`) persisted empty profile shells — sample count,
16+
per-container percentiles and histograms were dropped. `Save` now writes the status subresource
17+
(`UpdateStatus`). Profiles created before the fix refill in place on the next ticks.
18+
- **Scan ignored the operator.** `selectUsageProvider` never selected the operator tier; it went
19+
straight from Prometheus to the metrics-server snapshot. It now prefers the operator's history
20+
(Tier 0) when `UsageProfile`s exist, with a per-workload metrics-server fallback so coverage is
21+
not lost while the operator warms up.
22+
- Sub-hour windows render as `12m` instead of a broken-looking `0h`.
23+
24+
### Changed
25+
- **Confidence is now data-maturity gated.** Time-series tiers (operator/Prometheus/OpenCost)
26+
earn their high base only as window *and* sample coverage accumulate, so a freshly-installed
27+
operator with two readings reads **low**, not a false ~85%.
28+
- **Confidence is shown as bands** (`▒ low · ▓ med · █ high`) instead of a false-precision
29+
percentage; the exact % remains under `--explain`.
30+
- The scan `data:` banner reports the tier that actually backed the findings (dominant tier),
31+
not the provider's declared tier — honest during operator warm-up.
32+
33+
## [0.1.0] — 2026-06-01
34+
35+
Initial public release.
36+
37+
### Added
38+
- `kubectl tidy scan` — read-only efficiency score, dollar waste, and rightsizing
39+
recommendations with confidence + evidence; `--explain`, `--output json`.
40+
- `kubectl tidy diff` — exact, reversible `kubectl patch` per recommendation.
41+
- `kubectl tidy pr` — GitOps change set (patch files + Markdown PR body).
42+
- `kubectl tidy init` / `uninstall` — install/remove the CRDs + read-only operator from
43+
binary-embedded manifests.
44+
- Data ladder: metrics-server snapshot, Prometheus (auto-detected, Tier 1), and the in-cluster
45+
**kubetidy operator** (Tier 0 history with no Prometheus).
46+
- **OpenCost** integration for precise allocated cost (auto-detected, or `--opencost-url`; Tier 2).
47+
- CRDs: `UsageProfile`, `ClusterUsageSummary`, `Recommendation`.
48+
- Install via **krew**, `curl | sh`, or pre-built archives; tagged GitHub release pipeline
49+
(GoReleaser) with an auto-rendered krew manifest.
50+
51+
[Unreleased]: https://github.com/mayur-tolexo/kubetidy/compare/v0.1.0...HEAD
52+
[0.1.0]: https://github.com/mayur-tolexo/kubetidy/releases/tag/v0.1.0

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ and **acting on** the recommendations. kubetidy is built around trust:
3131
monthly dollar figure.
3232
- **Every number shows its work.** `--explain` reveals the exact query, window, sample
3333
count, variance, and policy behind each recommendation.
34+
- **Confidence you can trust.** Each recommendation is graded **▒ low · ▓ med · █ high**, and
35+
the grade *grows as history accumulates* — a freshly-installed operator with a couple of
36+
readings reads **low**, never a false "85%". It only earns **high** once a real window of
37+
samples backs it (`--explain` shows the exact %).
3438
- **Read-only and reversible by design.** kubetidy never mutates your cluster. `diff` prints
3539
the exact, reversible `kubectl patch`; `pr` writes a GitOps change set you review and merge.
3640

@@ -284,10 +288,13 @@ All defaults are surfaced in `--explain` and overridable. The number is never a
284288

285289
## Status
286290

287-
🚧 **Active development.** `scan`, `diff`, `pr`, and `init` work today, with a read-only
288-
operator, Prometheus auto-detection, and OpenCost auto-detection for precise cost (Tier 2). See
289-
the [roadmap](ROADMAP.md) for what is next (guarded apply, multi-cluster), and
290-
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the high-level design and flow diagrams.
291+
🚀 **v0.1.0 released** — install via [krew](#install), `curl | sh`, or pre-built archives.
292+
293+
`scan`, `diff`, `pr`, and `init` work today, with a read-only operator that records real
294+
Tier‑0 history (no Prometheus), Prometheus auto-detection (Tier 1), OpenCost auto-detection for
295+
precise cost (Tier 2), and confidence grading that scales with data maturity. See the
296+
[changelog](CHANGELOG.md) for what's new, the [roadmap](ROADMAP.md) for what's next (guarded
297+
apply, multi-cluster), and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design.
291298

292299
## Contributing
293300

ROADMAP.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,14 @@ This roadmap is intentionally public and open for discussion — please weigh in
5050
binary; no manual `kubectl apply`.
5151
- [ ] Guarded `apply` with auto-rollback on SLO regression
5252
- [x] Tier 2: OpenCost integration for precise allocated cost (auto-detected, or `--opencost-url`)
53+
- [x] **Recommendation CRDs** — the operator writes a per-workload `Recommendation` (the LLM
54+
target; rules-engine source today). Multi-cluster aggregation still to come.
55+
- [x] **Confidence that grows with data** — recommendations are graded low/med/high and gated
56+
on data maturity, so warm-up history isn't passed off as high-confidence.
5357
- [ ] Accountability: per-team showback + budgets
54-
- [ ] Recommendation CRDs + multi-cluster aggregation (continuous, in-cluster)
58+
- [ ] Multi-cluster aggregation (continuous, in-cluster)
5559
- [ ] Cleanup detectors (orphaned services/PVCs, idle namespaces, zombie workloads)
60+
- [ ] Interactive `scan -i` TUI to browse/filter recommendations and drill into `--explain`
5661

5762
## 💼 Commercial (open-core)
5863

docs/ARCHITECTURE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ read-only with respect to workloads — it never evicts or resizes. `kubectl tid
112112
(`internal/installer`) applies the embedded CRD + operator manifests so setup is a single CLI
113113
command. See [design/operator.md](design/operator.md).
114114

115+
`UsageProfile` (like the other CRDs) declares a **status subresource**, so the store writes spec
116+
via `Create`/`Update` and the recorded history via a follow-up `UpdateStatus` — a plain update
117+
silently drops `status`. On the read side the scan prefers the operator (`usage.DetectOperator`
118+
`NewOperatorProvider`) and wraps it in a `fallbackProvider` so workloads the operator has not
119+
profiled yet are still covered by the metrics-server snapshot (no coverage regression during
120+
warm-up).
121+
122+
## Confidence and the honest tier banner
123+
124+
`internal/rightsizer` grades each recommendation with a **data-maturity** model: snapshot/static
125+
tiers use a fixed low base, while time-series tiers (operator, Prometheus, OpenCost) earn their
126+
high base only as `maturity = min(window/72h, log10(samples)/log10(8000))` approaches 1, then
127+
lose ground to observed variance. So two readings over a few minutes score "low", not a
128+
misleading "85%". The CLI surfaces this as bands (low/med/high); the exact percentage is in
129+
`--explain`. The scan's `data:` banner reports the tier that **actually** backed the findings
130+
(the dominant tier of the recommendations), not the provider's declared tier — so during
131+
operator warm-up it honestly reads `snapshot` until profiles mature.
132+
115133
```mermaid
116134
flowchart LR
117135
timer([every scrape interval]) --> sample[sample metrics-server]

internal/cli/orchestration_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,11 @@ func TestSelectUsageProviderPrefersOperatorWhenProfilesExist(t *testing.T) {
289289
if p.Name() != "kubetidy operator" {
290290
t.Errorf("provider = %q, want kubetidy operator (Tier 0)", p.Name())
291291
}
292-
if !strings.Contains(strings.Join(warnings, " "), "operator history") {
293-
t.Errorf("warnings = %v, want an operator-history note", warnings)
292+
// No blanket note is emitted for the operator: the data banner states the tier and the
293+
// per-recommendation evidence shows any snapshot fallback, so a "using operator" note would
294+
// overclaim during warm-up.
295+
if strings.Contains(strings.Join(warnings, " "), "operator") {
296+
t.Errorf("did not expect an operator note, got %v", warnings)
294297
}
295298
}
296299

internal/cli/scan.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ func selectUsageProvider(clients *kube.Clients, f *scanFlags, warnings *[]string
156156
// so workloads the operator has not profiled yet are still covered (no coverage regression
157157
// while the operator warms up).
158158
if usage.DetectOperator(clients.Dynamic) {
159-
*warnings = append(*warnings, "using kubetidy operator history (Tier 0); metrics-server snapshot covers any not-yet-profiled workloads")
159+
// No note here: the data banner already states the tier, and the per-recommendation
160+
// evidence line shows which workloads (if any) fell back to a snapshot. A blanket
161+
// "using operator history" note overclaims while the operator is still warming up.
160162
return usage.NewFallbackProvider(
161163
usage.NewOperatorProvider(clients.Dynamic),
162164
usage.NewMetricsServerProvider(clients.Metrics),

internal/model/model.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,31 @@ type Confidence struct {
176176
// Percent returns the confidence as an integer percentage 0..100.
177177
func (c Confidence) Percent() int { return int(c.Score*100 + 0.5) }
178178

179+
// ConfidenceBand is a coarse, human-facing bucket for a confidence score. A precise percentage
180+
// implies false precision ("85%" from two samples is misleading), so the UI leads with the band
181+
// and keeps the number for detail views.
182+
type ConfidenceBand string
183+
184+
// The three confidence buckets, from least to most trustworthy.
185+
const (
186+
ConfidenceLow ConfidenceBand = "low"
187+
ConfidenceMedium ConfidenceBand = "med"
188+
ConfidenceHigh ConfidenceBand = "high"
189+
)
190+
191+
// Band buckets the score: < 0.60 low, < 0.80 medium, otherwise high. The low cutoff sits above
192+
// the snapshot ceiling (0.60) so a single-snapshot recommendation never reads above "low".
193+
func (c Confidence) Band() ConfidenceBand {
194+
switch {
195+
case c.Score < 0.60:
196+
return ConfidenceLow
197+
case c.Score < 0.80:
198+
return ConfidenceMedium
199+
default:
200+
return ConfidenceHigh
201+
}
202+
}
203+
179204
// Recommendation is the central, action-ready unit kubetidy produces. The MVP only reads,
180205
// but this carries enough to generate a patch later (target ref, container, current vs
181206
// proposed), so the future action layer is a new consumer, not a rewrite.

internal/model/model_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,22 @@ func TestDefaultPolicy(t *testing.T) {
6464
t.Error("default policy should be downsize-only on snapshot data")
6565
}
6666
}
67+
68+
func TestConfidenceBand(t *testing.T) {
69+
cases := []struct {
70+
score float64
71+
want ConfidenceBand
72+
}{
73+
{0.05, ConfidenceLow},
74+
{0.59, ConfidenceLow},
75+
{0.60, ConfidenceMedium},
76+
{0.79, ConfidenceMedium},
77+
{0.80, ConfidenceHigh},
78+
{0.99, ConfidenceHigh},
79+
}
80+
for _, c := range cases {
81+
if got := (Confidence{Score: c.score}).Band(); got != c.want {
82+
t.Errorf("Band(%.2f) = %q, want %q", c.score, got, c.want)
83+
}
84+
}
85+
}

internal/report/report.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,12 @@ func writeRecommendations(b *strings.Builder, recs []model.Recommendation, opts
163163
// Flushing to an in-memory strings.Builder cannot fail.
164164
_, _ = fmt.Fprintf(tw, " WORKLOAD\tCPU\tMEM\tSAVINGS\tCONF\n")
165165
for _, rec := range recs {
166-
_, _ = fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%d%%\n",
166+
_, _ = fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n",
167167
workloadLabel(rec),
168168
cpuTransition(rec),
169169
memTransition(rec),
170170
savingsCell(rec),
171-
rec.Confidence.Percent(),
171+
confidenceCell(rec),
172172
)
173173
}
174174
_ = tw.Flush()
@@ -199,7 +199,7 @@ func writeRecommendations(b *strings.Builder, recs []model.Recommendation, opts
199199
func writeLegend(b *strings.Builder, opts Options) {
200200
lines := []string{
201201
"CPU/MEM = current → proposed request · SAVINGS = $/mo (↑ grow = reliability, not a saving)",
202-
"conf = confidence in this recommendation; higher data tiers (Prometheus, OpenCost) score higher.",
202+
"CONF = confidence: ▒ low · ▓ med · █ high — grows with data history (tier, window, samples). --explain shows the %.",
203203
}
204204
for _, l := range lines {
205205
if opts.Color {
@@ -234,6 +234,20 @@ func savingsCell(rec model.Recommendation) string {
234234
return fmt.Sprintf("%s/mo", formatDollarsAbs(rec.MonthlySavings))
235235
}
236236

237+
// confidenceCell renders the qualitative confidence band with a shaded glyph that reads as a
238+
// fill level even without color. A precise percentage is intentionally omitted here (it implies
239+
// false precision); the number is available under --explain.
240+
func confidenceCell(rec model.Recommendation) string {
241+
switch rec.Confidence.Band() {
242+
case model.ConfidenceHigh:
243+
return "█ high"
244+
case model.ConfidenceMedium:
245+
return "▓ med"
246+
default:
247+
return "▒ low"
248+
}
249+
}
250+
237251
// JSON renders a stable machine-readable schema of the scan result.
238252
//
239253
// The schema is exactly the JSON encoding of model.ScanResult: an object with
@@ -265,7 +279,7 @@ func Explain(w io.Writer, rec model.Recommendation) error {
265279
} else {
266280
fmt.Fprintf(&b, " savings: %s / month\n", formatSignedDollars(rec.MonthlySavings))
267281
}
268-
fmt.Fprintf(&b, " confidence: %d%% (%s)\n", rec.Confidence.Percent(), rec.Confidence.Reason)
282+
fmt.Fprintf(&b, " confidence: %s (%d%%) — %s\n", rec.Confidence.Band(), rec.Confidence.Percent(), rec.Confidence.Reason)
269283
fmt.Fprintf(&b, " tier: %s\n", rec.Tier.String())
270284
if rec.Evidence != "" {
271285
fmt.Fprintf(&b, " evidence: %s\n", rec.Evidence)

internal/report/report_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ func TestTableRecommendationRow(t *testing.T) {
172172
if !strings.Contains(out, "$210/mo") {
173173
t.Errorf("missing savings\n--- got ---\n%s", out)
174174
}
175-
if !strings.Contains(out, "96%") {
176-
t.Errorf("missing confidence\n--- got ---\n%s", out)
175+
if !strings.Contains(out, "█ high") {
176+
t.Errorf("missing confidence band\n--- got ---\n%s", out)
177177
}
178178
// Evidence is indented under the row with the └ marker.
179179
if !strings.Contains(out, " └ P95 cpu 280m") {
@@ -256,7 +256,7 @@ func TestTableLegendShown(t *testing.T) {
256256
t.Fatalf("Table: %v", err)
257257
}
258258
out := buf.String()
259-
if !strings.Contains(out, "conf = confidence in this recommendation") {
259+
if !strings.Contains(out, "CONF = confidence:") {
260260
t.Errorf("missing legend\n--- got ---\n%s", out)
261261
}
262262
}
@@ -328,7 +328,7 @@ func TestExplain(t *testing.T) {
328328
"cpu: 2000m → 320m",
329329
"mem: 4Gi → 1.1Gi",
330330
"savings: $210 / month",
331-
"confidence: 96% (tier 1, 14d, low variance)",
331+
"confidence: high (96%) — tier 1, 14d, low variance",
332332
"tier: 1 (Prometheus)",
333333
"evidence: P95 cpu 280m",
334334
"derivation:",

0 commit comments

Comments
 (0)