pkg/prom/discovery.go
Rama main · réplica de lectura del repositorio original.
package prom
import (
"context"
"fmt"
"sort"
"strings"
"sync"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// wellKnownConcurrency bounds the parallel Service.Get fan-out over the
// well-known locations so discovery doesn't open a burst of connections to the
// API server at once.
const wellKnownConcurrency = 8
// CandidateSource describes how a candidate was found.
type CandidateSource string
const (
CandidateSourceWellKnown CandidateSource = "well_known"
CandidateSourceDynamic CandidateSource = "dynamic"
)
// Candidate is a Prometheus-compatible service the caller can attempt to
// reach. Discover populates the fields and orders candidates by priority,
// but does not probe — it leaves the transport choice (direct HTTP vs.
// port-forward vs. tunneled proxy) to the caller.
type Candidate struct {
Namespace string
Name string
Port int // service port (for in-cluster addressing)
TargetPort int // container port (for port-forwarding to the pod)
ClusterAddr string // http://{name}.{ns}.svc.cluster.local:{port}
BasePath string // e.g. "/select/0/prometheus" for vmselect
Score int // relative likelihood of being Prometheus
Source CandidateSource // well_known | dynamic
}
// DiscoverOptions tunes Discover's behavior.
type DiscoverOptions struct {
// IncludeDynamic controls whether a cluster-wide service scan is performed.
// The scan is an O(all services) List call plus a scoring pass; skip it
// for callers that only need a quick well-known check.
IncludeDynamic bool
// MaxDynamic caps the number of dynamic candidates returned. Default 5.
MaxDynamic int
// Logger is optional; if set, Discover emits verbose progress messages.
Logger func(format string, args ...interface{})
// Services, when set, supplies every Service in the cluster from a
// caller-held cache (an informer lister), replacing both the well-known
// Gets and the cluster-wide List with in-memory lookups. It must return
// ok=false unless the slice is the complete cluster-wide set: a partial
// view (a namespace-scoped watch, an unsynced cache) would silently drop
// candidates, so Discover falls back to the API instead. The returned
// objects are read, never modified.
Services func() (services []*corev1.Service, ok bool)
}
// wellKnownLoc is a namespace + service name where a Prometheus-compatible
// service is commonly installed.
type wellKnownLoc struct {
Namespace string
Name string
Port int // 0 = use service's first port
BasePath string // sub-path for Prometheus API
}
// WellKnownLocations is the ordered list of primary Prometheus-compatible
// service locations, ranked ahead of dynamically-discovered candidates.
var WellKnownLocations = []wellKnownLoc{
// VictoriaMetrics — monitoring namespace first (workload metrics)
{"monitoring", "victoria-metrics-victoria-metrics-single-server", 8428, ""},
{"monitoring", "victoria-metrics-single-server", 8428, ""},
{"monitoring", "vmsingle", 8428, ""},
{"monitoring", "vmselect", 8481, "/select/0/prometheus"},
{"victoria-metrics", "victoria-metrics-victoria-metrics-single-server", 8428, ""},
{"victoria-metrics", "victoria-metrics-single-server", 8428, ""},
{"victoria-metrics", "vmsingle", 8428, ""},
{"victoria-metrics", "vmselect", 8481, "/select/0/prometheus"},
// kube-prometheus-stack
{"monitoring", "kube-prometheus-stack-prometheus", 9090, ""},
{"monitoring", "prometheus-kube-prometheus-prometheus", 9090, ""},
{"monitoring", "prometheus-operated", 9090, ""},
// Standard Prometheus
{"opencost", "prometheus-server", 0, ""},
{"monitoring", "prometheus-server", 0, ""},
{"prometheus", "prometheus-server", 0, ""},
{"observability", "prometheus-server", 0, ""},
{"metrics", "prometheus-server", 0, ""},
{"kube-system", "prometheus", 0, ""},
{"default", "prometheus", 0, ""},
}
// fallbackWellKnownLocations are last-resort well-known metrics services, ranked
// AFTER dynamically-discovered candidates. caretta-vm serves the traffic source's
// eBPF link metrics and commonly lacks cluster workload (node/pod) metrics, so it
// must never outrank a real Prometheus — whether that Prometheus is in the primary
// well-known list or is only turned up by the dynamic scan. It is still returned
// as a candidate so a cluster whose only metrics backend is caretta-vm keeps working.
var fallbackWellKnownLocations = []wellKnownLoc{
{"caretta", "caretta-vm", 8428, ""},
}
// metricsNamespaces are commonly used for metrics services; used as a scoring
// signal in dynamic discovery.
var metricsNamespaces = map[string]bool{
"monitoring": true,
"prometheus": true,
"observability": true,
"metrics": true,
"victoria-metrics": true,
"caretta": true,
"opencost": true,
}
// skipNamespaces are excluded from dynamic discovery.
var skipNamespaces = map[string]bool{
"kube-public": true,
"kube-node-lease": true,
}
// nonQueryComponents are metrics-adjacent services that are commonly named or
// namespaced like Prometheus but do NOT serve the Prometheus HTTP query API
// (/api/v1/query). Discovery must never treat them as candidates: each one
// costs a serial port-forward attempt that can only ever fail. Matched as a
// substring against the service name and the app/name/component labels, so
// e.g. "kube-prometheus-stack-prometheus-node-exporter" is excluded by
// "node-exporter" even though it also contains "prometheus".
var nonQueryComponents = []string{
"node-exporter",
"kube-state-metrics",
"state-metrics",
"alertmanager",
"pushgateway",
"prometheus-adapter",
"grafana",
"blackbox",
"thanos-compact",
"thanos-rule", // covers thanos-ruler
"thanos-store",
"thanos-sidecar",
"thanos-receive",
// Control-plane scrape-target Services created by kube-prometheus-stack:
// they carry "prometheus" in the name but only proxy a component's /metrics,
// they don't serve the query API.
"kubelet",
"kube-etcd",
"kube-scheduler",
"kube-controller-manager",
"kube-proxy",
"coredns",
"kube-dns",
}
// queryEngineAppNames / queryEngineAppLabels are definitive identities of a
// Prometheus-family query engine. When one is present, the substring deny-list
// below must not fire — a real endpoint (e.g. a chart installed with release
// name "grafana", giving service name "grafana-prometheus-server", but
// app.kubernetes.io/name=prometheus) is a query API despite the generic token.
var queryEngineAppNames = map[string]bool{
"prometheus": true, "victoria-metrics-single": true, "vmsingle": true,
"vmselect": true, "thanos-query": true, "thanos-querier": true,
}
var queryEngineAppLabels = map[string]bool{
"prometheus": true, "prometheus-server": true, "vmsingle": true, "vmselect": true,
}
// isNonQueryService reports whether a service is a known non-query metrics
// component (exporter, operator, alertmanager, …) that cannot answer PromQL.
func isNonQueryService(svc corev1.Service) bool {
labels := svc.Labels
name := strings.ToLower(svc.Name)
appName := strings.ToLower(labels["app.kubernetes.io/name"])
component := strings.ToLower(labels["app.kubernetes.io/component"])
// A definitive query-engine identity wins over any name substring.
if queryEngineAppNames[appName] || queryEngineAppLabels[strings.ToLower(labels["app"])] {
return false
}
// The Prometheus operator is a control plane, not a query API. Match it by
// name suffix or exact label rather than a substring, so a real Prometheus
// whose name merely contains "operator" — e.g. "prometheus-operator-prometheus"
// from a release named "prometheus-operator" — is not excluded.
if strings.HasSuffix(name, "operator") || appName == "prometheus-operator" || component == "prometheus-operator" {
return true
}
haystack := []string{name, appName, component, strings.ToLower(labels["app"])}
for _, token := range nonQueryComponents {
for _, h := range haystack {
if h != "" && strings.Contains(h, token) {
return true
}
}
}
return false
}
// Discover enumerates candidate Prometheus-compatible services reachable to
// the given k8sClient. Well-known locations are returned first in declared
// priority order, optionally followed by dynamically-discovered services
// ranked by ScoreService.
//
// Discover does NOT probe any candidate — callers decide how to reach each
// (direct HTTP, port-forward, tunneled proxy) and then use
// pkg/prom.Client.Probe to validate.
func Discover(ctx context.Context, k8sClient kubernetes.Interface, opts DiscoverOptions) ([]Candidate, error) {
if k8sClient == nil {
return nil, fmt.Errorf("prom.Discover: k8sClient is nil")
}
if opts.MaxDynamic <= 0 {
opts.MaxDynamic = 5
}
logf := opts.Logger
if logf == nil {
logf = func(string, ...interface{}) {}
}
// cached is the caller's complete cluster-wide Service set, when it has
// one; every lookup below then reads it instead of the API.
var cached []*corev1.Service
fromCache := false
if opts.Services != nil {
cached, fromCache = opts.Services()
}
var byName map[string]*corev1.Service
if fromCache {
byName = make(map[string]*corev1.Service, len(cached))
for _, svc := range cached {
byName[svc.Namespace+"/"+svc.Name] = svc
}
}
collect := func(locs []wellKnownLoc, seen map[string]bool) []Candidate {
if fromCache {
found := make([]*corev1.Service, len(locs))
for i, loc := range locs {
found[i] = byName[loc.Namespace+"/"+loc.Name]
}
return assembleWellKnownLocations(locs, found, nil, seen, logf)
}
return collectWellKnownLocations(ctx, k8sClient, locs, seen, logf)
}
// seen de-duplicates candidates by namespace/name/port/basePath so a
// service reachable via more than one layer is probed and port-forwarded only
// once. Earlier layers win ties because they populate seen first.
seen := map[string]bool{}
// Layer 1: primary well-known locations, ranked ahead of everything else.
out := collect(WellKnownLocations, seen)
// Fallback locations (caretta-vm) are fetched now — so their keys enter `seen`
// and the dynamic scan below can't re-surface them as ordinary scored
// candidates — but they are held and appended LAST. caretta-vm serves only the
// traffic source's link metrics and commonly lacks cluster workload metrics, so
// a real Prometheus (well-known or dynamically discovered) must always outrank
// it; it stays a candidate only so a caretta-vm-only cluster keeps working.
fallback := collect(fallbackWellKnownLocations, seen)
if !opts.IncludeDynamic {
return append(out, fallback...), nil
}
// Layer 2: dynamic cluster-wide scan. The score only *ranks* candidates; it
// is not an admission gate. Anything we're certain can't answer PromQL is
// already excluded by ScoreService (isNonQueryService), and the caller's
// probe is the authoritative check — so a weak but positive score is kept
// and ranked low rather than dropped, which would silently miss a real but
// lightly-labeled Prometheus. MaxDynamic still bounds how many are returned.
svcs := cached
if !fromCache {
list, err := k8sClient.CoreV1().Services("").List(ctx, metav1.ListOptions{})
if err != nil {
// The cluster-wide list failed, but the well-known + fallback Gets above
// still succeeded — return them (including the caretta-vm fallback) rather
// than dropping the fallback tier. This is the get-but-not-list RBAC case.
logf("prom.Discover: failed to list services: %v", err)
return append(out, fallback...), nil
}
svcs = make([]*corev1.Service, len(list.Items))
for i := range list.Items {
svcs[i] = &list.Items[i]
}
}
// Services already claimed by the fallback tier must never resurface among the
// dynamic candidates — that would rank caretta-vm by score instead of forcing
// it last. Match by namespace/name (not the port-keyed `seen`), so a caretta-vm
// Service fronted by a non-8428 service port is still suppressed here.
fallbackNames := make(map[string]bool, len(fallback))
for _, c := range fallback {
fallbackNames[c.Namespace+"/"+c.Name] = true
}
var scored []Candidate
for _, svc := range svcs {
if fallbackNames[svc.Namespace+"/"+svc.Name] {
continue
}
score, bp, identity := ScoreService(*svc)
// Admit on a Prometheus-family identity signal, not on score alone: a
// namespace-only match (score without identity) would send the operator's
// configured Prometheus headers to an unrelated neighbor when probed.
if !identity {
continue
}
port := resolvePort(*svc, 0)
if seen[candidateKey(svc.Namespace, svc.Name, port, bp)] {
continue
}
scored = append(scored, Candidate{
Namespace: svc.Namespace,
Name: svc.Name,
Port: port,
TargetPort: resolveTargetPort(*svc, port),
ClusterAddr: buildClusterAddr(svc.Name, svc.Namespace, port),
BasePath: bp,
Score: score,
Source: CandidateSourceDynamic,
})
}
// Equal scores order by namespace/name so the MaxDynamic cut keeps the same
// candidates whatever order the Services arrived in (an API list is sorted,
// an informer lister is not).
sort.Slice(scored, func(i, j int) bool {
if scored[i].Score != scored[j].Score {
return scored[i].Score > scored[j].Score
}
if scored[i].Namespace != scored[j].Namespace {
return scored[i].Namespace < scored[j].Namespace
}
return scored[i].Name < scored[j].Name
})
if len(scored) > opts.MaxDynamic {
scored = scored[:opts.MaxDynamic]
}
out = append(out, scored...)
// Fallback tier last: after both the primary well-known list and the dynamic
// scan, so a real Prometheus always wins over caretta-vm when one exists.
out = append(out, fallback...)
return out, nil
}
// IsCandidateService reports whether Discover would return svc as a
// candidate: it sits at a well-known location, or carries a Prometheus-family
// identity signal. Callers watching Services use it to tell when a newly
// created one is worth a discovery run.
func IsCandidateService(svc *corev1.Service) bool {
if svc == nil {
return false
}
for _, locs := range [][]wellKnownLoc{WellKnownLocations, fallbackWellKnownLocations} {
for _, loc := range locs {
if loc.Namespace == svc.Namespace && loc.Name == svc.Name {
return true
}
}
}
_, _, identity := ScoreService(*svc)
return identity
}
// collectWellKnownLocations fetches the given well-known locations concurrently
// (targeted Gets, not a cluster-wide List, so it works for callers with
// get-but-not-list on Services) and returns the ones that exist as candidates in
// declared order. Locations whose key is already in seen are skipped; newly
// added keys are recorded so later layers de-duplicate against them.
func collectWellKnownLocations(ctx context.Context, k8sClient kubernetes.Interface, locs []wellKnownLoc, seen map[string]bool, logf func(string, ...interface{})) []Candidate {
found := make([]*corev1.Service, len(locs))
getErrs := make([]error, len(locs))
sem := make(chan struct{}, wellKnownConcurrency)
var wg sync.WaitGroup
for i, loc := range locs {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
svc, err := k8sClient.CoreV1().Services(loc.Namespace).Get(ctx, loc.Name, metav1.GetOptions{})
if err != nil {
getErrs[i] = err
return
}
found[i] = svc
})
}
wg.Wait()
return assembleWellKnownLocations(locs, found, getErrs, seen, logf)
}
// assembleWellKnownLocations turns the Services found at each location (nil
// when absent) into candidates in declared order. Logging happens here,
// serially — DiscoverOptions.Logger has no concurrency contract. getErrs may
// be nil when the lookups could not fail.
func assembleWellKnownLocations(locs []wellKnownLoc, found []*corev1.Service, getErrs []error, seen map[string]bool, logf func(string, ...interface{})) []Candidate {
var out []Candidate
for i, loc := range locs {
svc := found[i]
if svc == nil {
if getErrs != nil {
if err := getErrs[i]; err != nil && !apierrors.IsNotFound(err) {
logf("prom.Discover: error checking %s/%s: %v", loc.Namespace, loc.Name, err)
}
}
continue
}
port := resolvePort(*svc, loc.Port)
key := candidateKey(svc.Namespace, svc.Name, port, loc.BasePath)
if seen[key] {
continue
}
seen[key] = true
out = append(out, Candidate{
Namespace: svc.Namespace,
Name: svc.Name,
Port: port,
TargetPort: resolveTargetPort(*svc, port),
ClusterAddr: buildClusterAddr(svc.Name, svc.Namespace, port),
BasePath: loc.BasePath,
Source: CandidateSourceWellKnown,
})
}
return out
}
// promPortScore returns the heuristic weight for a port number matching a
// well-known Prometheus-family default (0 for anything else).
func promPortScore(port int32) int {
switch port {
case 9090: // Prometheus default
return 30
case 8428: // VictoriaMetrics single-node default
return 30
case 8481: // VictoriaMetrics vmselect default
return 25
case 9009, 10902: // Thanos Query (gRPC-era default; common HTTP port)
return 25
}
return 0
}
// ScoreService computes a heuristic score for a service being
// Prometheus-compatible. It returns the ranking score, an inferred BasePath for
// vmselect-style services, and whether the service carries a Prometheus-family
// *identity* signal (a name, label, or port-NAME match — NOT a bare port number).
// identity gates admission: a matched port number and namespace membership
// contribute to the score for ranking but must not, on their own, make an
// unrelated Service a candidate — discovery probes candidates with the operator's
// configured Prometheus headers, so a neighbor that merely shares a popular port
// (9090 is a common gRPC/plugin default too) or a namespace must not be admitted.
func ScoreService(svc corev1.Service) (score int, basePath string, identity bool) {
if svc.Spec.Type == corev1.ServiceTypeExternalName {
return 0, "", false
}
if skipNamespaces[svc.Namespace] {
return 0, "", false
}
if isNonQueryService(svc) {
return 0, "", false
}
labels := svc.Labels
appName := labels["app.kubernetes.io/name"]
appLabel := labels["app"]
component := labels["app.kubernetes.io/component"]
switch appName {
case "prometheus":
score += 100
identity = true
case "victoria-metrics-single", "vmsingle":
score += 100
identity = true
case "vmselect":
score += 90
basePath = "/select/0/prometheus"
identity = true
case "thanos-query", "thanos-querier":
score += 80
identity = true
}
switch appLabel {
case "prometheus", "prometheus-server":
score += 80
identity = true
case "vmsingle":
score += 80
identity = true
case "vmselect":
score += 80
basePath = "/select/0/prometheus"
identity = true
}
if score > 0 && component == "server" {
score += 20
}
for _, p := range svc.Spec.Ports {
// Credit a recognized port on either the service port or the container
// targetPort — a real Prometheus is often fronted by a service port of
// 80/http with targetPort 9090 — but only once per port entry. A bare port
// NUMBER is a ranking signal only: it must NOT set identity, because
// popular defaults like 9090 are shared with unrelated components (e.g. a
// gRPC plugin), and admitting on the number alone sends the operator's
// Prometheus headers to — and wastes a doomed port-forward on — that
// neighbor. A port explicitly NAMED "prometheus" is a genuine family signal.
if s := promPortScore(p.Port); s > 0 {
score += s
} else {
score += promPortScore(p.TargetPort.IntVal)
}
if strings.Contains(strings.ToLower(p.Name), "prometheus") {
score += 10
identity = true
}
}
nameLower := strings.ToLower(svc.Name)
if strings.Contains(nameLower, "prometheus") {
score += 20
identity = true
}
if strings.Contains(nameLower, "victoria") || strings.Contains(nameLower, "vmsingle") || strings.Contains(nameLower, "vmselect") {
score += 20
identity = true
if strings.Contains(nameLower, "vmselect") && basePath == "" {
basePath = "/select/0/prometheus"
}
}
if strings.Contains(nameLower, "thanos") {
score += 15
identity = true
}
// identity is set above only by a name, label, or port-name signal — genuine
// Prometheus-family membership. A bare port number and the metrics-namespace
// bonus below rank a candidate but must never admit one on their own, so the
// caller never probes (and never sends the operator's configured Prometheus
// headers to) an unrelated neighbor that merely shares port 9090 or a namespace.
if metricsNamespaces[svc.Namespace] {
score += 10
}
return score, basePath, identity
}
// Key identifies the backend independently of its direct or forwarded address.
func (c Candidate) Key() string {
return candidateKey(c.Namespace, c.Name, c.Port, c.BasePath)
}
func candidateKey(namespace, name string, port int, basePath string) string {
return fmt.Sprintf("%s/%s:%d%s", namespace, name, port, basePath)
}
func resolvePort(svc corev1.Service, defaultPort int) int {
if defaultPort != 0 {
return defaultPort
}
if len(svc.Spec.Ports) > 0 {
return int(svc.Spec.Ports[0].Port)
}
return 80
}
// resolveTargetPort returns the container port, for port-forwarding which
// bypasses the Service. When the service port differs from the container's
// targetPort (e.g., service:80 → container:9090), port-forward needs the
// container port.
func resolveTargetPort(svc corev1.Service, servicePort int) int {
for _, p := range svc.Spec.Ports {
if int(p.Port) == servicePort {
if p.TargetPort.IntVal > 0 {
return int(p.TargetPort.IntVal)
}
return servicePort
}
}
return servicePort
}
// buildClusterAddr returns the in-cluster HTTP URL for a service.
// A headless Service publishes A records for its ready pods under its own name,
// so the plain service address works for both kinds. Addressing a headless one as
// {service}-0.{service} assumes the StatefulSet is named after the Service: true
// for caretta-vm, false for kube-prometheus-stack's prometheus-operated, whose
// pod is prometheus-{release}-kube-prometheus-stack-prometheus-0. That guess made
// a reachable backend look unreachable in-cluster.
func buildClusterAddr(name, namespace string, port int) string {
return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", name, namespace, port)
}