Skip to content

Commit cf89387

Browse files
authored
Merge pull request #10 from mayur-tolexo/feat/opencost-pricing
feat(pricing): OpenCost-backed precise cost (Tier 2), auto-detected
2 parents 27caa52 + cbccfd2 commit cf89387

12 files changed

Lines changed: 648 additions & 25 deletions

File tree

README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,23 @@ and **acting on** the recommendations. kubetidy is built around trust:
3939
kubetidy auto-detects the best data source available and stamps every finding with the tier
4040
that proved it. It never fails hard — it degrades to whatever is present.
4141

42-
| Tier | Needs | You get | Confidence |
43-
|------|-------|---------|------------|
44-
| 0 | K8s API + **kubetidy operator** | historical P50/P95/max with **no Prometheus** | high |
45-
|| K8s API + metrics-server only | single live snapshot (fallback, conservative) | low |
46-
| 1 | + Prometheus | historical P50/P95/max over a window | high |
47-
| 2 | + OpenCost *(coming)* | precise allocated cost | high |
42+
There are two tiers — the difference is **how the dollars are priced**:
4843

49-
kubetidy prefers, in order: **Prometheus (Tier 1)****kubetidy operator (Tier 0)** → bare
50-
metrics-server snapshot (a conservative fallback). All are auto-detected; no flags required.
44+
| Tier | Needs | Usage data | Cost basis |
45+
|------|-------|------------|------------|
46+
| 1 | K8s API + metrics-server **or** Prometheus | live snapshot (metrics-server) or historical P50/P95/max (Prometheus / kubetidy operator) | **derived** node pricing (blended `$/core`, `$/GiB`, refined by instance type) |
47+
| 2 | Prometheus + **OpenCost** | historical P50/P95/max | **precise allocated cost** from OpenCost (spot / reserved / committed-use discounts included) |
48+
49+
Tier 1 works on **any cluster** with no cost dependency — the dollar figure is derived from
50+
blended cloud pricing (override with `--cpu-cost` / `--mem-cost`). Tier 2 kicks in when an
51+
in-cluster **OpenCost** is present (it needs Prometheus, which is why it sits above Tier 1):
52+
kubetidy auto-detects it and replaces the derived prices with real allocated cost. Point at one
53+
explicitly with `--opencost-url`.
54+
55+
Within Tier 1, kubetidy auto-detects the best **usage** source — Prometheus or the kubetidy
56+
operator (historical, high-confidence) over a bare metrics-server snapshot (a conservative
57+
fallback) — and stamps every finding with what proved it. No flags required; it never fails
58+
hard, degrading to whatever is present.
5159

5260
## Install
5361

@@ -277,8 +285,8 @@ All defaults are surfaced in `--explain` and overridable. The number is never a
277285
## Status
278286

279287
🚧 **Active development.** `scan`, `diff`, `pr`, and `init` work today, with a read-only
280-
operator (Tier 0) and Prometheus auto-detection. See the [roadmap](ROADMAP.md) for what is
281-
next (guarded apply, OpenCost cost, multi-cluster), and
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
282290
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the high-level design and flow diagrams.
283291

284292
## Contributing

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ This roadmap is intentionally public and open for discussion — please weigh in
4949
- [x] **`kubectl tidy init`** — install the CRD + operator from manifests embedded in the
5050
binary; no manual `kubectl apply`.
5151
- [ ] Guarded `apply` with auto-rollback on SLO regression
52-
- [ ] Tier 2: OpenCost integration for precise allocated cost
52+
- [x] Tier 2: OpenCost integration for precise allocated cost (auto-detected, or `--opencost-url`)
5353
- [ ] Accountability: per-team showback + budgets
5454
- [ ] Recommendation CRDs + multi-cluster aggregation (continuous, in-cluster)
5555
- [ ] Cleanup detectors (orphaned services/PVCs, idle namespaces, zombie workloads)

docs/ARCHITECTURE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ flowchart LR
9696
ts --> rec
9797
```
9898

99-
Tier 2 (OpenCost) is defined behind the `PriceProvider` interface and deferred.
99+
Cost is a separate axis from usage, behind the `pricing.Provider` interface. By default kubetidy
100+
derives prices from blended cloud rates (`pricing.NewConfigProvider`). When an in-cluster
101+
**OpenCost** is detected (`pricing.DetectOpenCost`) — or pointed at via `--opencost-url` — the
102+
`opencostProvider` queries its allocation API and serves precise per-workload allocated cost
103+
(Tier 2). OpenCost needs Prometheus, which is why Tier 2 sits above Tier 1.
100104

101105
## The operator (Tier 0)
102106

internal/cli/diff.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func newDiffCommand() *cobra.Command {
3333
flags.StringVar(&f.explain, "explain", "", "only show the patch for a single workload")
3434
flags.IntVar(&f.topN, "top", 20, "max patches to show (0 = all)")
3535
flags.StringVar(&f.prometheusURL, "prometheus-url", "", "Prometheus base URL (forces Tier 1)")
36+
flags.StringVar(&f.opencostURL, "opencost-url", "", "OpenCost base URL for precise cost (forces Tier 2; auto-detected otherwise)")
3637
flags.StringVar(&f.window, "window", "14d", "Prometheus lookback window")
3738
flags.Float64Var(&f.cpuCoreMonth, "cpu-cost", 0, "override $ per CPU core-month (0 = default)")
3839
flags.Float64Var(&f.memGiBMonth, "mem-cost", 0, "override $ per GiB-month (0 = default)")

internal/cli/orchestration_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package cli
22

33
import (
44
"context"
5+
"net/http"
6+
"net/http/httptest"
57
"strings"
68
"testing"
79

810
appsv1 "k8s.io/api/apps/v1"
911
corev1 "k8s.io/api/core/v1"
1012
"k8s.io/apimachinery/pkg/api/resource"
1113
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/client-go/kubernetes"
1215
kfake "k8s.io/client-go/kubernetes/fake"
1316
mfake "k8s.io/metrics/pkg/client/clientset/versioned/fake"
1417

@@ -191,3 +194,67 @@ func TestRunPRInjectedEmpty(t *testing.T) {
191194
t.Errorf("unexpected pr output:\n%s", out)
192195
}
193196
}
197+
198+
// --- price provider selection (Tier 1 derived vs Tier 2 OpenCost) ----------------------------
199+
200+
func TestSelectPriceProviderDefaultDerived(t *testing.T) {
201+
var warnings []string
202+
p := selectPriceProvider(context.Background(), orchFakeClients(t), &scanFlags{window: "14d"}, &warnings)
203+
if p.Name() != "node pricing" {
204+
t.Errorf("provider = %q, want node pricing (Tier 1 default)", p.Name())
205+
}
206+
}
207+
208+
func TestSelectPriceProviderExplicitOpenCost(t *testing.T) {
209+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
210+
_, _ = w.Write([]byte(`{"code":200,"data":[{"shop/api":{"name":"shop/api",` +
211+
`"properties":{"namespace":"shop","controller":"api"},` +
212+
`"cpuCoreHours":10,"cpuCost":0.5,"ramByteHours":1073741824000,"ramCost":0.2}}]}`))
213+
}))
214+
defer srv.Close()
215+
216+
var warnings []string
217+
p := selectPriceProvider(context.Background(), orchFakeClients(t),
218+
&scanFlags{opencostURL: srv.URL, window: "7d"}, &warnings)
219+
if p.Name() != "OpenCost" {
220+
t.Errorf("provider = %q, want OpenCost", p.Name())
221+
}
222+
if !strings.Contains(strings.Join(warnings, " "), "OpenCost") {
223+
t.Errorf("warnings = %v, want an OpenCost note", warnings)
224+
}
225+
}
226+
227+
func TestSelectPriceProviderBadOpenCostFallsBack(t *testing.T) {
228+
var warnings []string
229+
p := selectPriceProvider(context.Background(), orchFakeClients(t),
230+
&scanFlags{opencostURL: "http://opencost.invalid:9003", window: "7d"}, &warnings)
231+
if p.Name() != "node pricing" {
232+
t.Errorf("provider = %q, want node pricing fallback", p.Name())
233+
}
234+
if !strings.Contains(strings.Join(warnings, " "), "unavailable") {
235+
t.Errorf("warnings = %v, want an unavailable note", warnings)
236+
}
237+
}
238+
239+
func TestSelectPriceProviderAutoDetect(t *testing.T) {
240+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
241+
_, _ = w.Write([]byte(`{"code":200,"data":[{"shop/api":{"name":"shop/api",` +
242+
`"properties":{"namespace":"shop","controller":"api"},` +
243+
`"cpuCoreHours":10,"cpuCost":0.5,"ramByteHours":1073741824000,"ramCost":0.2}}]}`))
244+
}))
245+
defer srv.Close()
246+
247+
// Override the detection seam so auto-detect resolves to the reachable test server.
248+
origDetect := detectOpenCost
249+
defer func() { detectOpenCost = origDetect }()
250+
detectOpenCost = func(kubernetes.Interface) string { return srv.URL }
251+
252+
var warnings []string
253+
p := selectPriceProvider(context.Background(), orchFakeClients(t), &scanFlags{window: "7d"}, &warnings)
254+
if p.Name() != "OpenCost" {
255+
t.Errorf("provider = %q, want auto-detected OpenCost", p.Name())
256+
}
257+
if !strings.Contains(strings.Join(warnings, " "), "auto-detected OpenCost") {
258+
t.Errorf("warnings = %v, want auto-detected OpenCost note", warnings)
259+
}
260+
}

internal/cli/scan.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type scanFlags struct {
2222
explain string
2323
topN int
2424
prometheusURL string
25+
opencostURL string
2526
window string
2627
cpuCoreMonth float64
2728
memGiBMonth float64
@@ -53,6 +54,7 @@ func newScanCommand() *cobra.Command {
5354
flags.StringVar(&f.explain, "explain", "", "show full derivation for a single workload")
5455
flags.IntVar(&f.topN, "top", 20, "max recommendations to show (0 = all)")
5556
flags.StringVar(&f.prometheusURL, "prometheus-url", "", "Prometheus base URL (forces Tier 1)")
57+
flags.StringVar(&f.opencostURL, "opencost-url", "", "OpenCost base URL for precise cost (forces Tier 2; auto-detected otherwise)")
5658
flags.StringVar(&f.window, "window", "14d", "Prometheus lookback window")
5759
flags.Float64Var(&f.cpuCoreMonth, "cpu-cost", 0, "override $ per CPU core-month (0 = default)")
5860
flags.Float64Var(&f.memGiBMonth, "mem-cost", 0, "override $ per GiB-month (0 = default)")
@@ -100,14 +102,7 @@ func runEngineWithClients(ctx context.Context, f *scanFlags, clients *kube.Clien
100102
var warnings []string
101103
usageProvider := selectUsageProvider(clients, f, &warnings)
102104

103-
cfg := pricing.DefaultConfig()
104-
if f.cpuCoreMonth > 0 {
105-
cfg.CPUCoreMonth = f.cpuCoreMonth
106-
}
107-
if f.memGiBMonth > 0 {
108-
cfg.MemGiBMonth = f.memGiBMonth
109-
}
110-
priceProvider := pricing.NewConfigProvider(cfg)
105+
priceProvider := selectPriceProvider(ctx, clients, f, &warnings)
111106

112107
engine := &scan.Engine{
113108
Workloads: workloads,
@@ -158,6 +153,47 @@ func selectUsageProvider(clients *kube.Clients, f *scanFlags, warnings *[]string
158153
return usage.NewMetricsServerProvider(clients.Metrics)
159154
}
160155

156+
// detectOpenCost is a seam over pricing.DetectOpenCost so tests can point auto-detection at a
157+
// reachable endpoint (the real detector returns an in-cluster URL that a unit test can't reach).
158+
var detectOpenCost = pricing.DetectOpenCost
159+
160+
// selectPriceProvider picks the cost source, mirroring selectUsageProvider:
161+
// - an explicit --opencost-url forces Tier 2 (precise allocated cost);
162+
// - otherwise kubetidy auto-detects an in-cluster OpenCost/Kubecost service and uses it when
163+
// its allocation API answers, upgrading cost to Tier 2 with no configuration;
164+
// - failing that, it falls back to derived node pricing (configProvider), honoring the
165+
// --cpu-cost / --mem-cost overrides.
166+
//
167+
// Every upgrade or failed attempt is recorded in warnings so the report explains the source.
168+
func selectPriceProvider(ctx context.Context, clients *kube.Clients, f *scanFlags, warnings *[]string) pricing.Provider {
169+
cfg := pricing.DefaultConfig()
170+
if f.cpuCoreMonth > 0 {
171+
cfg.CPUCoreMonth = f.cpuCoreMonth
172+
}
173+
if f.memGiBMonth > 0 {
174+
cfg.MemGiBMonth = f.memGiBMonth
175+
}
176+
fallback := pricing.NewConfigProvider(cfg)
177+
178+
if f.opencostURL != "" {
179+
p, err := pricing.NewOpenCostProvider(ctx, f.opencostURL, f.window)
180+
if err == nil {
181+
*warnings = append(*warnings, fmt.Sprintf("using OpenCost at %s for cost (Tier 2)", f.opencostURL))
182+
return p
183+
}
184+
*warnings = append(*warnings, fmt.Sprintf("OpenCost unavailable (%v); using derived node pricing", err))
185+
return fallback
186+
}
187+
188+
if url := detectOpenCost(clients.Kube); url != "" {
189+
if p, err := pricing.NewOpenCostProvider(ctx, url, f.window); err == nil {
190+
*warnings = append(*warnings, fmt.Sprintf("auto-detected OpenCost at %s (Tier 2 cost)", url))
191+
return p
192+
}
193+
}
194+
return fallback
195+
}
196+
161197
func render(result model.ScanResult, f *scanFlags) error {
162198
switch f.output {
163199
case "json":

internal/pricing/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ type Config struct {
1717
//
1818
// These are deliberate approximations: ~$24.0 per vCPU-core-month and ~$3.2 per GiB-month,
1919
// roughly a general-purpose vCPU/GiB blended across AWS/GCP/Azure on-demand list pricing as
20-
// of 2025. They give the Tier-0/Tier-1 scan a credible dollar figure without any external
21-
// cost source. When OpenCost is available (Tier 2, deferred), these derived prices are
22-
// replaced by precise allocated cost. Operators can override via flags.
20+
// of 2025. They give the scan a credible dollar figure without any external cost source
21+
// (Tier 1). When OpenCost is available (Tier 2), opencostProvider replaces these derived
22+
// prices with precise allocated cost. Operators can override via flags.
2323
func DefaultConfig() Config {
2424
return Config{CPUCoreMonth: 24.0, MemGiBMonth: 3.2}
2525
}

internal/pricing/detect.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package pricing
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
corev1 "k8s.io/api/core/v1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/client-go/kubernetes"
11+
)
12+
13+
// ocCandidate is a well-known OpenCost / Kubecost Service location to probe, in priority order.
14+
type ocCandidate struct {
15+
namespace string
16+
service string
17+
port int32
18+
}
19+
20+
// opencostCandidates lists common in-cluster OpenCost (and Kubecost) API service locations,
21+
// most standard first. OpenCost's API listens on 9003; Kubecost's cost-analyzer on 9090.
22+
var opencostCandidates = []ocCandidate{
23+
{"opencost", "opencost", 9003},
24+
{"opencost", "opencost-opencost", 9003},
25+
{"monitoring", "opencost", 9003},
26+
{"kubecost", "kubecost-cost-analyzer", 9090},
27+
{"kubecost", "kubecost-cost-analyzer-abc", 9090},
28+
}
29+
30+
// DetectOpenCost probes the cluster for a well-known OpenCost (or Kubecost) Service and returns
31+
// its in-cluster API base URL (e.g. "http://opencost.opencost.svc:9003") when found, or ""
32+
// when none is present. Like DetectPrometheus, it only confirms the Service exists; the caller
33+
// (NewOpenCostProvider) validates that the API actually answers. Best-effort, never errors.
34+
func DetectOpenCost(client kubernetes.Interface) string {
35+
if client == nil {
36+
return ""
37+
}
38+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
39+
defer cancel()
40+
41+
for _, c := range opencostCandidates {
42+
svc, err := client.CoreV1().Services(c.namespace).Get(ctx, c.service, metav1.GetOptions{})
43+
if err != nil || svc == nil {
44+
continue
45+
}
46+
port := c.port
47+
if p := firstServicePort(svc); p != 0 {
48+
port = p
49+
}
50+
return fmt.Sprintf("http://%s.%s.svc:%d", c.service, c.namespace, port)
51+
}
52+
return ""
53+
}
54+
55+
// firstServicePort returns the first declared port of a Service, or 0 if none.
56+
func firstServicePort(svc *corev1.Service) int32 {
57+
if svc == nil || len(svc.Spec.Ports) == 0 {
58+
return 0
59+
}
60+
return svc.Spec.Ports[0].Port
61+
}

internal/pricing/detect_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package pricing
2+
3+
import (
4+
"testing"
5+
6+
corev1 "k8s.io/api/core/v1"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"k8s.io/apimachinery/pkg/runtime"
9+
"k8s.io/client-go/kubernetes/fake"
10+
)
11+
12+
func svc(name, namespace string, ports ...int32) *corev1.Service {
13+
s := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}}
14+
for _, p := range ports {
15+
s.Spec.Ports = append(s.Spec.Ports, corev1.ServicePort{Port: p})
16+
}
17+
return s
18+
}
19+
20+
func TestDetectOpenCost_NilClient(t *testing.T) {
21+
if got := DetectOpenCost(nil); got != "" {
22+
t.Fatalf("DetectOpenCost(nil) = %q, want empty", got)
23+
}
24+
}
25+
26+
func TestDetectOpenCost_EmptyCluster(t *testing.T) {
27+
if got := DetectOpenCost(fake.NewSimpleClientset()); got != "" {
28+
t.Fatalf("DetectOpenCost(empty) = %q, want empty", got)
29+
}
30+
}
31+
32+
func TestDetectOpenCost(t *testing.T) {
33+
tests := []struct {
34+
name string
35+
services []*corev1.Service
36+
want string
37+
}{
38+
{
39+
name: "standard opencost in opencost namespace",
40+
services: []*corev1.Service{svc("opencost", "opencost", 9003)},
41+
want: "http://opencost.opencost.svc:9003",
42+
},
43+
{
44+
name: "kubecost cost-analyzer",
45+
services: []*corev1.Service{svc("kubecost-cost-analyzer", "kubecost", 9090)},
46+
want: "http://kubecost-cost-analyzer.kubecost.svc:9090",
47+
},
48+
{
49+
name: "declared port overrides candidate default",
50+
services: []*corev1.Service{svc("opencost", "opencost", 8080)},
51+
want: "http://opencost.opencost.svc:8080",
52+
},
53+
{
54+
name: "no service yields empty",
55+
services: nil,
56+
want: "",
57+
},
58+
}
59+
for _, tc := range tests {
60+
t.Run(tc.name, func(t *testing.T) {
61+
objs := make([]runtime.Object, 0, len(tc.services))
62+
for _, s := range tc.services {
63+
objs = append(objs, s)
64+
}
65+
client := fake.NewSimpleClientset(objs...)
66+
if got := DetectOpenCost(client); got != tc.want {
67+
t.Errorf("DetectOpenCost = %q, want %q", got, tc.want)
68+
}
69+
})
70+
}
71+
}

0 commit comments

Comments
 (0)