Skip to content

Commit 0d75256

Browse files
committed
fix(scan): reach auto-detected Prometheus/OpenCost via the API server proxy
scan runs on the user's machine, but auto-detection returned an in-cluster Service DNS name (*.svc) that only resolves inside the cluster. Every query failed with "no such host" and the scan silently reported a misleading 100/100 $0 (zero usage mistaken for zero waste) — hit on a real cluster. - Reach an auto-detected Prometheus/OpenCost through the Kubernetes API server proxy (/api/v1/namespaces/<ns>/services/<svc>:<port>/proxy), reusing the kubeconfig's API server address + credentials. Works wherever kubectl works, no port-forward. - Validate reachability (a trivial `up` query / live allocation fetch) before committing to Tier 1 / Tier 2; an unreachable endpoint falls back to the operator / metrics-server / derived pricing with a clear note. - Expose kube.Clients.RESTConfig; add DetectPrometheusEndpoint / DetectOpenCostEndpoint (+ struct), NewPrometheusProviderViaAPIProxy, NewOpenCostProviderViaAPIProxy, and usage.Reachable. Auto-detect now sits behind provider seams (prometheusAutoProvider / openCostAutoProvider). - An explicit --prometheus-url / --opencost-url is still used directly.
1 parent 2cb4acc commit 0d75256

11 files changed

Lines changed: 389 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ fixes/UX).
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Auto-detected Prometheus/OpenCost now reachable from `scan`.** Detection returned an
12+
in-cluster Service DNS name (`*.svc`) that doesn't resolve on the user's machine, so every
13+
query failed with "no such host" and the scan silently reported a misleading `100/100, $0`.
14+
`scan` now reaches an auto-detected Prometheus or OpenCost through the **Kubernetes API server
15+
proxy** (reusing the kubeconfig's address + credentials — works wherever kubectl works, no
16+
port-forward), and **validates reachability** before committing to Tier 1/Tier 2; an
17+
unreachable endpoint falls back to the operator / metrics-server / derived pricing with a clear
18+
note instead of producing empty results. An explicit `--prometheus-url` / `--opencost-url` is
19+
still used directly.
20+
1021
### Added
1122
- **`kubetidy init --with-opencost`** — deploy a complete Tier-2 cost stack into the cluster from
1223
embedded manifests, so scans get precise allocated cost out of the box. OpenCost needs

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,12 @@ See [docs/design/operator.md](docs/design/operator.md) for the design.
200200
## High-confidence scans with Prometheus (Tier 1)
201201

202202
Already run Prometheus? kubetidy auto-detects the common in-cluster service names, so plain
203-
`kubectl tidy scan` upgrades to Tier 1 automatically. Or point it explicitly:
203+
`kubectl tidy scan` upgrades to Tier 1 automatically. Because `scan` runs on your machine (where
204+
in-cluster Service DNS doesn't resolve), it reaches an auto-detected Prometheus — and OpenCost —
205+
**through the Kubernetes API server proxy**, reusing your kubeconfig's credentials: no
206+
port-forward, works wherever `kubectl` works. It validates the endpoint answers before using it,
207+
so a detected-but-unreachable service falls back cleanly instead of reporting empty results. Or
208+
point it explicitly (used as-is, e.g. a `localhost` port-forward):
204209

205210
```sh
206211
kubectl tidy scan --prometheus-url http://prometheus.monitoring.svc:9090

internal/cli/orchestration_test.go

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import (
1414
"k8s.io/apimachinery/pkg/runtime"
1515
"k8s.io/apimachinery/pkg/runtime/schema"
1616
dynfake "k8s.io/client-go/dynamic/fake"
17-
"k8s.io/client-go/kubernetes"
1817
kfake "k8s.io/client-go/kubernetes/fake"
1918
mfake "k8s.io/metrics/pkg/client/clientset/versioned/fake"
2019

2120
"github.com/kubetidy/kubetidy/internal/apis/usageprofile"
2221
"github.com/kubetidy/kubetidy/internal/kube"
2322
"github.com/kubetidy/kubetidy/internal/model"
23+
"github.com/kubetidy/kubetidy/internal/pricing"
24+
"github.com/kubetidy/kubetidy/internal/usage"
2425
)
2526

2627
func orchInt32(v int32) *int32 { return &v }
@@ -129,13 +130,21 @@ func TestSelectUsageProviderBadPrometheusFallsBack(t *testing.T) {
129130
}
130131

131132
func TestSelectUsageProviderAutoDetect(t *testing.T) {
132-
svc := &corev1.Service{
133-
ObjectMeta: metav1.ObjectMeta{Name: "prometheus-server", Namespace: "monitoring"},
134-
Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 80}}},
133+
// Override the auto-detect seam to return a reachable Prometheus provider, as it would in a
134+
// cluster where the API-server-proxy endpoint answers. (The real seam routes through the
135+
// proxy + validates reachability, which needs a live cluster.)
136+
orig := prometheusAutoProvider
137+
defer func() { prometheusAutoProvider = orig }()
138+
prometheusAutoProvider = func(_ *kube.Clients, window string) (usage.Provider, string, bool) {
139+
p, err := usage.NewPrometheusProvider("http://prom.test:9090", window)
140+
if err != nil {
141+
t.Fatalf("building test prometheus provider: %v", err)
142+
}
143+
return p, "monitoring/prometheus-server:80 via the API server proxy", true
135144
}
136-
clients := &kube.Clients{Kube: kfake.NewSimpleClientset(svc), Metrics: mfake.NewSimpleClientset()}
145+
137146
var warnings []string
138-
p := selectUsageProvider(clients, &scanFlags{window: "14d"}, &warnings)
147+
p := selectUsageProvider(orchFakeClients(t), &scanFlags{window: "14d"}, &warnings)
139148
if p.Name() != "prometheus" {
140149
t.Errorf("provider = %q, want auto-detected prometheus", p.Name())
141150
}
@@ -144,6 +153,25 @@ func TestSelectUsageProviderAutoDetect(t *testing.T) {
144153
}
145154
}
146155

156+
func TestSelectUsageProviderUnreachablePromFallsBack(t *testing.T) {
157+
// A detected-but-unreachable Prometheus must fall through (here, to metrics-server) and leave
158+
// a fall-back note, rather than committing to an empty Tier 1.
159+
orig := prometheusAutoProvider
160+
defer func() { prometheusAutoProvider = orig }()
161+
prometheusAutoProvider = func(_ *kube.Clients, _ string) (usage.Provider, string, bool) {
162+
return nil, "found Prometheus service monitoring/prometheus-server but it did not answer queries; falling back to operator/metrics-server", false
163+
}
164+
165+
var warnings []string
166+
p := selectUsageProvider(orchFakeClients(t), &scanFlags{window: "14d"}, &warnings)
167+
if p.Name() != "metrics-server" {
168+
t.Errorf("provider = %q, want metrics-server fallback", p.Name())
169+
}
170+
if !strings.Contains(strings.Join(warnings, " "), "did not answer") {
171+
t.Errorf("warnings = %v, want a fall-back note", warnings)
172+
}
173+
}
174+
147175
func TestSelectUsageProviderDefaultMetricsServer(t *testing.T) {
148176
var warnings []string
149177
p := selectUsageProvider(orchFakeClients(t), &scanFlags{window: "14d"}, &warnings)
@@ -248,10 +276,17 @@ func TestSelectPriceProviderAutoDetect(t *testing.T) {
248276
}))
249277
defer srv.Close()
250278

251-
// Override the detection seam so auto-detect resolves to the reachable test server.
252-
origDetect := detectOpenCost
253-
defer func() { detectOpenCost = origDetect }()
254-
detectOpenCost = func(kubernetes.Interface) string { return srv.URL }
279+
// Override the auto-detect seam so it resolves to the reachable test server (the real seam
280+
// routes through the API server proxy, which needs a live cluster).
281+
origDetect := openCostAutoProvider
282+
defer func() { openCostAutoProvider = origDetect }()
283+
openCostAutoProvider = func(ctx context.Context, _ *kube.Clients, window string) (pricing.Provider, string, bool) {
284+
p, err := pricing.NewOpenCostProvider(ctx, srv.URL, window)
285+
if err != nil {
286+
return nil, "", false
287+
}
288+
return p, "opencost/opencost:9003 via the API server proxy", true
289+
}
255290

256291
var warnings []string
257292
p := selectPriceProvider(context.Background(), orchFakeClients(t), &scanFlags{window: "7d"}, &warnings)

internal/cli/scan.go

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"time"
78

89
"github.com/spf13/cobra"
910

@@ -160,11 +161,11 @@ func selectUsageProvider(clients *kube.Clients, f *scanFlags, warnings *[]string
160161
return usage.NewMetricsServerProvider(clients.Metrics)
161162
}
162163

163-
if url := usage.DetectPrometheus(clients.Kube); url != "" {
164-
if p, err := usage.NewPrometheusProvider(url, f.window); err == nil {
165-
*warnings = append(*warnings, fmt.Sprintf("auto-detected Prometheus at %s (Tier 1)", url))
166-
return p
167-
}
164+
if p, note, ok := prometheusAutoProvider(clients, f.window); ok {
165+
*warnings = append(*warnings, fmt.Sprintf("auto-detected Prometheus (%s) — Tier 1", note))
166+
return p
167+
} else if note != "" {
168+
*warnings = append(*warnings, note)
168169
}
169170

170171
// No Prometheus: prefer the kubetidy operator's recorded history (Tier 0) when it's
@@ -184,9 +185,48 @@ func selectUsageProvider(clients *kube.Clients, f *scanFlags, warnings *[]string
184185
return usage.NewMetricsServerProvider(clients.Metrics)
185186
}
186187

187-
// detectOpenCost is a seam over pricing.DetectOpenCost so tests can point auto-detection at a
188-
// reachable endpoint (the real detector returns an in-cluster URL that a unit test can't reach).
189-
var detectOpenCost = pricing.DetectOpenCost
188+
// prometheusAutoProvider auto-detects an in-cluster Prometheus and returns a Tier-1 provider
189+
// that reaches it through the Kubernetes API server proxy — which works from the user's machine,
190+
// where in-cluster Service DNS does not resolve. It validates reachability (a trivial query)
191+
// before committing, so an unreachable or wrong endpoint falls through to the operator /
192+
// metrics-server instead of silently producing empty, misleading results. The returned note is
193+
// the source label on success, or a fall-back reason when a service was found but unusable
194+
// (empty otherwise). It is a seam so tests can inject a reachable provider without a cluster.
195+
var prometheusAutoProvider = func(clients *kube.Clients, window string) (usage.Provider, string, bool) {
196+
ep, ok := usage.DetectPrometheusEndpoint(clients.Kube)
197+
if !ok {
198+
return nil, "", false
199+
}
200+
p, err := usage.NewPrometheusProviderViaAPIProxy(clients.RESTConfig, ep, window)
201+
if err != nil {
202+
return nil, fmt.Sprintf("found Prometheus service %s/%s but could not build a client (%v); falling back",
203+
ep.Namespace, ep.Service, err), false
204+
}
205+
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
206+
defer cancel()
207+
if !usage.Reachable(ctx, p) {
208+
return nil, fmt.Sprintf("found Prometheus service %s/%s but it did not answer queries; falling back to operator/metrics-server",
209+
ep.Namespace, ep.Service), false
210+
}
211+
return p, fmt.Sprintf("%s/%s:%d via the API server proxy", ep.Namespace, ep.Service, ep.Port), true
212+
}
213+
214+
// openCostAutoProvider mirrors prometheusAutoProvider for cost: it auto-detects an in-cluster
215+
// OpenCost/Kubecost and returns a Tier-2 provider that reaches it through the API server proxy.
216+
// NewOpenCostProviderViaAPIProxy already queries the allocation API at construction, so a
217+
// non-answering endpoint yields ok=false and we fall back to derived pricing. Seam for tests.
218+
var openCostAutoProvider = func(ctx context.Context, clients *kube.Clients, window string) (pricing.Provider, string, bool) {
219+
ep, ok := pricing.DetectOpenCostEndpoint(clients.Kube)
220+
if !ok {
221+
return nil, "", false
222+
}
223+
p, err := pricing.NewOpenCostProviderViaAPIProxy(ctx, clients.RESTConfig, ep, window)
224+
if err != nil {
225+
return nil, fmt.Sprintf("found OpenCost service %s/%s but it did not answer (%v); using derived node pricing",
226+
ep.Namespace, ep.Service, err), false
227+
}
228+
return p, fmt.Sprintf("%s/%s:%d via the API server proxy", ep.Namespace, ep.Service, ep.Port), true
229+
}
190230

191231
// selectPriceProvider picks the cost source, mirroring selectUsageProvider:
192232
// - an explicit --opencost-url forces Tier 2 (precise allocated cost);
@@ -216,11 +256,11 @@ func selectPriceProvider(ctx context.Context, clients *kube.Clients, f *scanFlag
216256
return fallback
217257
}
218258

219-
if url := detectOpenCost(clients.Kube); url != "" {
220-
if p, err := pricing.NewOpenCostProvider(ctx, url, f.window); err == nil {
221-
*warnings = append(*warnings, fmt.Sprintf("auto-detected OpenCost at %s (Tier 2 cost)", url))
222-
return p
223-
}
259+
if p, note, ok := openCostAutoProvider(ctx, clients, f.window); ok {
260+
*warnings = append(*warnings, fmt.Sprintf("auto-detected OpenCost (%s) — Tier 2 cost", note))
261+
return p
262+
} else if note != "" {
263+
*warnings = append(*warnings, note)
224264
}
225265
return fallback
226266
}

internal/kube/kube.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,22 @@ import (
1313
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1414
"k8s.io/client-go/dynamic"
1515
"k8s.io/client-go/kubernetes"
16+
"k8s.io/client-go/rest"
1617
"k8s.io/client-go/tools/clientcmd"
1718
)
1819

1920
// Clients bundles the Kubernetes API clients and resolved context metadata
2021
// that the rest of kubetidy needs.
2122
type Clients struct {
22-
Kube kubernetes.Interface
23-
Metrics metricsv.Interface
24-
Dynamic dynamic.Interface
25-
Context string
26-
Namespace string
23+
Kube kubernetes.Interface
24+
Metrics metricsv.Interface
25+
Dynamic dynamic.Interface
26+
// RESTConfig is the resolved client config. It's kept so callers can reach in-cluster
27+
// services (e.g. Prometheus) through the API server proxy, which works from outside the
28+
// cluster where in-cluster Service DNS does not resolve.
29+
RESTConfig *rest.Config
30+
Context string
31+
Namespace string
2732
}
2833

2934
// Load builds API clients from the user's kubeconfig, honoring the active
@@ -85,11 +90,12 @@ func Load(contextOverride, namespaceOverride string) (*Clients, error) {
8590
}
8691

8792
return &Clients{
88-
Kube: kubeClient,
89-
Metrics: metricsClient,
90-
Dynamic: dynamicClient,
91-
Context: contextName,
92-
Namespace: namespace,
93+
Kube: kubeClient,
94+
Metrics: metricsClient,
95+
Dynamic: dynamicClient,
96+
RESTConfig: restConfig,
97+
Context: contextName,
98+
Namespace: namespace,
9399
}, nil
94100
}
95101

internal/pricing/detect.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,26 @@ var opencostCandidates = []ocCandidate{
2727
{"kubecost", "kubecost-cost-analyzer-abc", 9090},
2828
}
2929

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 {
30+
// OpenCostEndpoint identifies a detected in-cluster OpenCost/Kubecost Service by coordinates, so
31+
// the caller can reach it either directly (in-cluster) or through the API server proxy (from
32+
// outside the cluster).
33+
type OpenCostEndpoint struct {
34+
Namespace string
35+
Service string
36+
Port int32
37+
}
38+
39+
// InClusterURL is the direct, in-cluster API base URL (only resolvable from inside the cluster).
40+
func (e OpenCostEndpoint) InClusterURL() string {
41+
return fmt.Sprintf("http://%s.%s.svc:%d", e.Service, e.Namespace, e.Port)
42+
}
43+
44+
// DetectOpenCostEndpoint probes the cluster for a well-known OpenCost (or Kubecost) Service and
45+
// returns its coordinates when found. It only confirms the Service exists; the caller validates
46+
// that the API answers. Best-effort, never errors.
47+
func DetectOpenCostEndpoint(client kubernetes.Interface) (OpenCostEndpoint, bool) {
3548
if client == nil {
36-
return ""
49+
return OpenCostEndpoint{}, false
3750
}
3851
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
3952
defer cancel()
@@ -47,9 +60,20 @@ func DetectOpenCost(client kubernetes.Interface) string {
4760
if p := firstServicePort(svc); p != 0 {
4861
port = p
4962
}
50-
return fmt.Sprintf("http://%s.%s.svc:%d", c.service, c.namespace, port)
63+
return OpenCostEndpoint{Namespace: c.namespace, Service: c.service, Port: port}, true
64+
}
65+
return OpenCostEndpoint{}, false
66+
}
67+
68+
// DetectOpenCost returns the in-cluster API base URL of a detected OpenCost, or "" when none is
69+
// present. Retained for callers/tests that want the direct URL; new code should prefer
70+
// DetectOpenCostEndpoint so it can route through the API server proxy.
71+
func DetectOpenCost(client kubernetes.Interface) string {
72+
ep, ok := DetectOpenCostEndpoint(client)
73+
if !ok {
74+
return ""
5175
}
52-
return ""
76+
return ep.InClusterURL()
5377
}
5478

5579
// firstServicePort returns the first declared port of a Service, or 0 if none.

internal/pricing/opencost.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"strings"
1111
"time"
1212

13+
"k8s.io/client-go/rest"
14+
1315
"github.com/kubetidy/kubetidy/internal/model"
1416
)
1517

@@ -64,6 +66,24 @@ func NewOpenCostProvider(ctx context.Context, baseURL, window string) (Provider,
6466
return newOpenCostProviderWithClient(ctx, &http.Client{Timeout: 10 * time.Second}, baseURL, window)
6567
}
6668

69+
// NewOpenCostProviderViaAPIProxy builds a Tier-2 provider that reaches an in-cluster OpenCost
70+
// Service through the Kubernetes API server proxy — so `scan`, running on the user's machine,
71+
// can talk to a Service whose DNS only resolves in-cluster, with no port-forward. It reuses the
72+
// kubeconfig's API server address and credentials.
73+
func NewOpenCostProviderViaAPIProxy(ctx context.Context, cfg *rest.Config, ep OpenCostEndpoint, window string) (Provider, error) {
74+
if cfg == nil {
75+
return nil, fmt.Errorf("opencost: nil rest config")
76+
}
77+
transport, err := rest.TransportFor(cfg)
78+
if err != nil {
79+
return nil, fmt.Errorf("opencost: building API server transport: %w", err)
80+
}
81+
base := strings.TrimRight(cfg.Host, "/") +
82+
fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy", ep.Namespace, ep.Service, ep.Port)
83+
httpc := &http.Client{Transport: transport, Timeout: 15 * time.Second}
84+
return newOpenCostProviderWithClient(ctx, httpc, base, window)
85+
}
86+
6787
// newOpenCostProviderWithClient is the testable core: it accepts the HTTP client so tests can
6888
// point it at an httptest.Server.
6989
func newOpenCostProviderWithClient(ctx context.Context, httpc *http.Client, baseURL, window string) (Provider, error) {

0 commit comments

Comments
 (0)