Ctrl K
ComunidadGuías…

pkg/prom/discovery_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package prom

import (
	"context"
	"fmt"
	"reflect"
	"testing"

	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/util/intstr"
	"k8s.io/client-go/kubernetes/fake"
	ktesting "k8s.io/client-go/testing"
)

func TestScoreService_TableDriven(t *testing.T) {
	tests := []struct {
		name         string
		svc          corev1.Service
		wantMin      int
		wantMax      int
		wantBasePath string
	}{
		{
			name: "plain prometheus by app.kubernetes.io/name + port",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "prometheus-server",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
				},
				Spec: corev1.ServiceSpec{
					Ports: []corev1.ServicePort{{Port: 9090}},
				},
			},
			wantMin: 100 + 30 + 20 + 10, // name + port + name-contains + metrics ns
			wantMax: 500,
		},
		{
			name: "vmselect sets basePath",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "vmselect",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "vmselect"},
				},
				Spec: corev1.ServiceSpec{
					Ports: []corev1.ServicePort{{Port: 8481}},
				},
			},
			wantMin:      90 + 25 + 20 + 10,
			wantMax:      200,
			wantBasePath: "/select/0/prometheus",
		},
		{
			name: "thanos-query scores lower than prometheus but non-zero",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "thanos-query",
					Namespace: "observability",
					Labels:    map[string]string{"app.kubernetes.io/name": "thanos-query"},
				},
				Spec: corev1.ServiceSpec{
					Ports: []corev1.ServicePort{{Port: 9009}},
				},
			},
			wantMin: 80 + 25 + 15 + 10,
			wantMax: 200,
		},
		{
			name: "unrelated service scores zero",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "redis",
					Namespace: "default",
					Labels:    map[string]string{"app": "redis"},
				},
				Spec: corev1.ServiceSpec{
					Ports: []corev1.ServicePort{{Port: 6379}},
				},
			},
			wantMax: 0,
		},
		{
			name: "ExternalName excluded",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: "prometheus", Namespace: "monitoring"},
				Spec:       corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName},
			},
			wantMax: 0,
		},
		{
			name: "skip-namespace excluded",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "prometheus",
					Namespace: "kube-public",
					Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
				},
			},
			wantMax: 0,
		},
		// Non-query components must score zero even though their names contain
		// "prometheus" and they live in a metrics namespace — these are the
		// exact services that were polluting discovery and wasting port-forwards.
		{
			name: "node-exporter excluded despite prometheus in name",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "kube-prometheus-stack-prometheus-node-exporter",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "prometheus-node-exporter"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9100}}},
			},
			wantMax: 0,
		},
		{
			name: "kube-state-metrics excluded",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "kube-prometheus-stack-kube-state-metrics",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "kube-state-metrics"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 8080}}},
			},
			wantMax: 0,
		},
		{
			name: "prometheus-operator excluded",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "kube-prometheus-stack-operator",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/component": "prometheus-operator"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 443}}},
			},
			wantMax: 0,
		},
		{
			name: "alertmanager excluded",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "kube-prometheus-stack-alertmanager",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "alertmanager"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9093}}},
			},
			wantMax: 0,
		},
		{
			name: "pushgateway excluded",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "prometheus-pushgateway",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "prometheus-pushgateway"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9091}}},
			},
			wantMax: 0,
		},
		{
			name: "definitive prometheus label overrides a deny-list substring in the name",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "grafana-prometheus-server", // "grafana" substring
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090}}},
			},
			wantMin: 1, // must not be excluded by the "grafana" token
			wantMax: 500,
		},
		{
			name: "prometheus from an operator-named release is kept",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: "prometheus-operator-prometheus", Namespace: "monitoring"},
				Spec:       corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090}}},
			},
			wantMin: 1, // must not be excluded (name contains "operator" but isn't the operator)
			wantMax: 500,
		},
		{
			name: "kubelet scrape-target excluded despite prometheus in name",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "kube-prometheus-stack-kubelet",
					Namespace: "kube-system",
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 10250}}},
			},
			wantMax: 0,
		},
		{
			name: "thanos-store excluded (not the query API)",
			svc: corev1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:      "thanos-store",
					Namespace: "monitoring",
					Labels:    map[string]string{"app.kubernetes.io/name": "thanos-store"},
				},
				Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 10902}}},
			},
			wantMax: 0,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			score, bp, _ := ScoreService(tc.svc)
			if score < tc.wantMin || (tc.wantMax > 0 && score > tc.wantMax) {
				t.Errorf("score=%d, want in [%d, %d]", score, tc.wantMin, tc.wantMax)
			}
			if tc.wantMax == 0 && score != 0 {
				t.Errorf("score=%d, want 0", score)
			}
			if tc.wantBasePath != "" && bp != tc.wantBasePath {
				t.Errorf("basePath=%q, want %q", bp, tc.wantBasePath)
			}
		})
	}
}

func TestDiscover_WellKnownFirst(t *testing.T) {
	// Install a standard prometheus-server at a well-known location
	// plus an unrelated redis service.
	wellKnown := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "prometheus-server", Namespace: "monitoring"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.1",
			Ports:     []corev1.ServicePort{{Port: 80, TargetPort: intstr.FromInt(9090)}},
		},
	}
	redis := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "redis", Namespace: "default"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.2",
			Ports:     []corev1.ServicePort{{Port: 6379}},
		},
	}
	// Install an additional unknown-but-scoring dynamic candidate.
	thanos := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "thanos-query",
			Namespace: "observability",
			Labels:    map[string]string{"app.kubernetes.io/name": "thanos-query"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.3",
			Ports:     []corev1.ServicePort{{Port: 9009}},
		},
	}

	k8s := fake.NewSimpleClientset(wellKnown, redis, thanos)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 3})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(cands) < 2 {
		t.Fatalf("want at least 2 candidates, got %d", len(cands))
	}

	// First must be the well-known match.
	if cands[0].Source != CandidateSourceWellKnown {
		t.Errorf("cands[0].Source = %q, want well_known", cands[0].Source)
	}
	if cands[0].Namespace != "monitoring" || cands[0].Name != "prometheus-server" {
		t.Errorf("cands[0] = %s/%s, want monitoring/prometheus-server", cands[0].Namespace, cands[0].Name)
	}
	if cands[0].ClusterAddr != "http://prometheus-server.monitoring.svc.cluster.local:80" {
		t.Errorf("cluster addr = %q", cands[0].ClusterAddr)
	}
	if cands[0].TargetPort != 9090 {
		t.Errorf("TargetPort = %d, want 9090", cands[0].TargetPort)
	}

	// Dynamic thanos match should be present.
	var sawDynamicThanos bool
	for _, c := range cands {
		if c.Source == CandidateSourceDynamic && c.Name == "thanos-query" {
			sawDynamicThanos = true
			break
		}
	}
	if !sawDynamicThanos {
		t.Errorf("expected dynamic thanos candidate; got %+v", cands)
	}

	// Redis must not appear in any form.
	for _, c := range cands {
		if c.Name == "redis" {
			t.Errorf("redis should not be a candidate: %+v", c)
		}
	}
}

func TestDiscover_DeDupesWellKnownAndDynamic(t *testing.T) {
	// A service that matches both a well-known location (monitoring/prometheus-server)
	// and scores in the dynamic scan (app.kubernetes.io/name=prometheus) must
	// appear exactly once, sourced from the well-known layer.
	svc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "prometheus-server",
			Namespace: "monitoring",
			Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.1",
			Ports:     []corev1.ServicePort{{Port: 9090}},
		},
	}

	k8s := fake.NewSimpleClientset(svc)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}

	var count int
	for _, c := range cands {
		if c.Namespace == "monitoring" && c.Name == "prometheus-server" {
			count++
			if c.Source != CandidateSourceWellKnown {
				t.Errorf("de-duped candidate should retain well_known source, got %q", c.Source)
			}
		}
	}
	if count != 1 {
		t.Fatalf("want monitoring/prometheus-server exactly once, got %d (all: %+v)", count, cands)
	}
}

func TestDiscover_ExcludesControlPlaneScrapeTargets(t *testing.T) {
	// A kube-prometheus-stack control-plane scrape-target Service carries
	// "prometheus" in its name but only proxies a component's /metrics; it must
	// not become a candidate — it can't answer PromQL and would waste a
	// port-forward. Excluded by name (a component token), not by score.
	noise := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "kube-prometheus-stack-kube-etcd",
			Namespace: "kube-system",
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "None",
			Ports:     []corev1.ServicePort{{Port: 2381}},
		},
	}

	k8s := fake.NewSimpleClientset(noise)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(cands) != 0 {
		t.Fatalf("control-plane scrape-target service became a candidate: %+v", cands)
	}
}

func TestDiscover_ExcludesNamespaceOnlyNeighbor(t *testing.T) {
	// An unrelated Service that only matches by sharing a metrics namespace (no
	// name/label/port Prometheus signal) must NOT become a candidate — discovery
	// probes candidates with the operator's configured Prometheus headers, so a
	// namespace neighbor would receive credentials it shouldn't.
	neighbor := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "billing-api",
			Namespace: "monitoring",
			Labels:    map[string]string{"app": "billing"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.42",
			Ports:     []corev1.ServicePort{{Port: 8080, Name: "http"}},
		},
	}

	if _, _, identity := ScoreService(*neighbor); identity {
		t.Fatal("namespace-only neighbor reported a Prometheus identity signal")
	}

	k8s := fake.NewSimpleClientset(neighbor)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(cands) != 0 {
		t.Fatalf("namespace-only neighbor became a candidate (would be probed with creds): %+v", cands)
	}
}

func TestDiscover_KeepsPrometheusWithNamedTargetPort(t *testing.T) {
	// An unlabeled Prometheus behind service port 80 with a *named* targetPort
	// scores only on its name (a named targetPort has no numeric value to
	// credit). Score ranks, it does not gate — so this real endpoint must still
	// be discovered and left for the probe to validate, not silently dropped.
	svc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "prometheus-server", Namespace: "apps"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.6",
			Ports:     []corev1.ServicePort{{Port: 80, TargetPort: intstr.FromString("web")}},
		},
	}

	k8s := fake.NewSimpleClientset(svc)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	var found bool
	for _, c := range cands {
		if c.Namespace == "apps" && c.Name == "prometheus-server" {
			found = true
		}
	}
	if !found {
		t.Fatalf("prometheus with a named targetPort was pruned: %+v", cands)
	}
}

func TestDiscover_KeepsUnlabeledThanosQuery(t *testing.T) {
	// An unlabeled thanos-query on its common HTTP port is a real query API and
	// must survive the dynamic score floor (name match + recognized port).
	svc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "thanos-query", Namespace: "obs"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.8",
			Ports:     []corev1.ServicePort{{Port: 10902}},
		},
	}

	k8s := fake.NewSimpleClientset(svc)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	var found bool
	for _, c := range cands {
		if c.Name == "thanos-query" {
			found = true
		}
	}
	if !found {
		t.Fatalf("unlabeled thanos-query was pruned: %+v", cands)
	}
}

func TestDiscover_KeepsUnlabeledCustomPrometheus(t *testing.T) {
	// A real Prometheus in a non-standard namespace with no conventional labels
	// and a service port of 80 scores only on the name match, but it is a
	// genuine query endpoint and must still be discovered — pruning must not be
	// so aggressive that it drops it.
	svc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "prometheus-server", Namespace: "platform"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.9",
			Ports:     []corev1.ServicePort{{Port: 80, TargetPort: intstr.FromInt(9090)}},
		},
	}

	k8s := fake.NewSimpleClientset(svc)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	var found bool
	for _, c := range cands {
		if c.Namespace == "platform" && c.Name == "prometheus-server" {
			found = true
		}
	}
	if !found {
		t.Fatalf("unlabeled custom prometheus was pruned: %+v", cands)
	}
}

func TestDiscover_SkipsDynamicWhenDisabled(t *testing.T) {
	// Only a dynamic-scoring service is present (no well-known match).
	prom := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "my-prometheus",
			Namespace: "observability",
			Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.5",
			Ports:     []corev1.ServicePort{{Port: 9090}},
		},
	}

	k8s := fake.NewSimpleClientset(prom)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: false})
	if err != nil {
		t.Fatal(err)
	}
	if len(cands) != 0 {
		t.Errorf("expected no candidates when dynamic is disabled and no well-known match; got %d", len(cands))
	}
}

func TestDiscover_HeadlessServiceUsesPlainServiceDNS(t *testing.T) {
	headless := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "prometheus-server", Namespace: "monitoring"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "None",
			Ports:     []corev1.ServicePort{{Port: 9090}},
		},
	}
	k8s := fake.NewSimpleClientset(headless)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{})
	if err != nil {
		t.Fatal(err)
	}
	if len(cands) != 1 {
		t.Fatalf("want 1 candidate, got %d", len(cands))
	}
	// A headless Service publishes A records for its ready pods under its own
	// name; the old {service}-0. pod prefix assumed the StatefulSet was named
	// after the Service and left non-conforming backends unreachable.
	want := "http://prometheus-server.monitoring.svc.cluster.local:9090"
	if cands[0].ClusterAddr != want {
		t.Errorf("cluster addr = %q, want %q", cands[0].ClusterAddr, want)
	}
}

func TestDiscover_NilClient(t *testing.T) {
	_, err := Discover(context.Background(), nil, DiscoverOptions{})
	if err == nil {
		t.Error("expected error for nil client")
	}
}

// TestDiscover_LoggerCalledSerially guards the DiscoverOptions.Logger contract:
// well-known lookups run concurrently, but the Logger must not be — a caller can
// pass an unsynchronized callback. The reactor makes every well-known Get fail
// (non-NotFound) so each would log; an unsynchronized slice append from parallel
// workers would trip the race detector.
func TestDiscover_LoggerCalledSerially(t *testing.T) {
	k8s := fake.NewSimpleClientset()
	k8s.PrependReactor("get", "services", func(ktesting.Action) (bool, runtime.Object, error) {
		return true, nil, fmt.Errorf("simulated API error")
	})

	var logs []string
	_, err := Discover(context.Background(), k8s, DiscoverOptions{
		Logger: func(format string, args ...interface{}) {
			logs = append(logs, fmt.Sprintf(format, args...)) // deliberately unsynchronized
		},
	})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(logs) == 0 {
		t.Fatal("expected error logs from failed well-known lookups")
	}
}

// TestDiscover_CarettaVMRanksAfterDynamicPrometheus pins that caretta-vm, a
// last-resort fallback that commonly lacks workload metrics, never outranks a
// real Prometheus — including one discoverable only via the dynamic scan
// (non-standard namespace/name). Before the fallback tier existed, caretta-vm
// (a well-known location) sorted ahead of every dynamic candidate.
func TestDiscover_CarettaVMRanksAfterDynamicPrometheus(t *testing.T) {
	carettaVM := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "caretta-vm", Namespace: "caretta"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.9",
			Ports:     []corev1.ServicePort{{Port: 8428}},
		},
	}
	// A real Prometheus at a non-standard location — found only by the dynamic
	// scan via its app.kubernetes.io/name identity label.
	dynamicProm := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "prom",
			Namespace: "myteam",
			Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.10",
			Ports:     []corev1.ServicePort{{Port: 9090}},
		},
	}

	k8s := fake.NewSimpleClientset(carettaVM, dynamicProm)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}

	idx := func(ns, name string) int {
		for i, c := range cands {
			if c.Namespace == ns && c.Name == name {
				return i
			}
		}
		return -1
	}
	dynIdx := idx("myteam", "prom")
	carettaIdx := idx("caretta", "caretta-vm")
	if dynIdx < 0 {
		t.Fatalf("dynamic prometheus not discovered: %+v", cands)
	}
	if carettaIdx < 0 {
		t.Fatalf("caretta-vm fallback not discovered: %+v", cands)
	}
	if carettaIdx < dynIdx {
		t.Fatalf("caretta-vm (idx %d) ranked before dynamic prometheus (idx %d) — fallback must rank last", carettaIdx, dynIdx)
	}
	if carettaIdx != len(cands)-1 {
		t.Fatalf("caretta-vm at idx %d, want last candidate (%d): %+v", carettaIdx, len(cands)-1, cands)
	}
}

// TestDiscover_CarettaVMReturnedWhenSoleBackend guards the safety net: when
// caretta-vm is the only metrics backend, it must still be returned so the
// cluster keeps working — demoting it to a fallback tier must not drop it.
func TestDiscover_CarettaVMReturnedWhenSoleBackend(t *testing.T) {
	carettaVM := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "caretta-vm", Namespace: "caretta"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.9",
			Ports:     []corev1.ServicePort{{Port: 8428}},
		},
	}
	k8s := fake.NewSimpleClientset(carettaVM)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(cands) != 1 || cands[0].Namespace != "caretta" || cands[0].Name != "caretta-vm" {
		t.Fatalf("want sole caretta-vm candidate, got %+v", cands)
	}
}

// TestDiscover_FallbackSurvivesListFailure guards finding-1: when the cluster
// list fails (get-but-not-list RBAC) but caretta-vm is fetchable via targeted
// Get, the fallback tier must still be returned — not silently dropped.
func TestDiscover_FallbackSurvivesListFailure(t *testing.T) {
	carettaVM := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "caretta-vm", Namespace: "caretta"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.9",
			Ports:     []corev1.ServicePort{{Port: 8428}},
		},
	}
	k8s := fake.NewSimpleClientset(carettaVM)
	k8s.PrependReactor("list", "services", func(ktesting.Action) (bool, runtime.Object, error) {
		return true, nil, fmt.Errorf("forbidden: cannot list services at cluster scope")
	})
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(cands) != 1 || cands[0].Namespace != "caretta" || cands[0].Name != "caretta-vm" {
		t.Fatalf("fallback dropped on list failure: got %+v", cands)
	}
}

// TestDiscover_CarettaVMLastEvenWithNonStandardServicePort guards finding-2: a
// caretta-vm Service fronted by service port 80 -> container 8428 keys as :80 in
// the dynamic scan but :8428 in the fallback tier. A port-keyed dedup alone would
// let it reappear among dynamics and lose the last-place invariant; name-based
// suppression keeps it single and last.
func TestDiscover_CarettaVMLastEvenWithNonStandardServicePort(t *testing.T) {
	carettaVM := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "caretta-vm", Namespace: "caretta"},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.9",
			Ports:     []corev1.ServicePort{{Port: 80, TargetPort: intstr.FromInt(8428)}},
		},
	}
	dynamicProm := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "prom",
			Namespace: "myteam",
			Labels:    map[string]string{"app.kubernetes.io/name": "prometheus"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.10",
			Ports:     []corev1.ServicePort{{Port: 9090}},
		},
	}
	k8s := fake.NewSimpleClientset(carettaVM, dynamicProm)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	carettaCount, carettaIdx := 0, -1
	for i, c := range cands {
		if c.Name == "caretta-vm" {
			carettaCount++
			carettaIdx = i
		}
	}
	if carettaCount != 1 {
		t.Fatalf("caretta-vm appeared %d times, want 1: %+v", carettaCount, cands)
	}
	if carettaIdx != len(cands)-1 {
		t.Fatalf("caretta-vm at idx %d, want last (%d): %+v", carettaIdx, len(cands)-1, cands)
	}
}

// TestDiscover_ExcludesPortOnlyPortMatch reproduces issue #1356: a Service that
// matches only on a well-known port NUMBER (9090 here, shared with gRPC plugins
// such as CloudNativePG's barman-cloud) carries no Prometheus-family identity —
// no name, label, or port-name signal — and must NOT be admitted as a candidate.
// Admitting it wastes a doomed port-forward that, in-cluster on a read-only
// ServiceAccount, surfaces to the user as a confusing "Prometheus not connected".
func TestDiscover_ExcludesPortOnlyPortMatch(t *testing.T) {
	barman := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:        "barman-cloud",
			Namespace:   "cnpg-system",
			Annotations: map[string]string{"cnpg.io/pluginPort": "9090"},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "10.0.0.20",
			Ports:     []corev1.ServicePort{{Port: 9090}},
		},
	}

	// Ranking signal is present (port scores) but identity must be false.
	score, _, identity := ScoreService(*barman)
	if identity {
		t.Fatalf("barman-cloud identity=true (a bare port number must not admit); score=%d", score)
	}
	if score == 0 {
		t.Fatalf("expected a non-zero ranking score from the port number, got 0")
	}

	k8s := fake.NewSimpleClientset(barman)
	cands, err := Discover(context.Background(), k8s, DiscoverOptions{IncludeDynamic: true, MaxDynamic: 5})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	for _, c := range cands {
		if c.Name == "barman-cloud" {
			t.Fatalf("port-only gRPC service admitted as a Prometheus candidate: %+v", cands)
		}
	}
}

// TestScoreService_IdentityRequiresFamilySignal locks the issue #1356 contract at
// the scoring layer: identity is set by a name/label/port-name signal, never by a
// bare port number.
func TestScoreService_IdentityRequiresFamilySignal(t *testing.T) {
	cases := []struct {
		name      string
		svc       corev1.Service
		wantIdent bool
	}{
		{"port-number only", corev1.Service{
			ObjectMeta: metav1.ObjectMeta{Name: "some-grpc-svc", Namespace: "apps"},
			Spec:       corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090}}},
		}, false},
		{"metrics namespace + port only", corev1.Service{
			ObjectMeta: metav1.ObjectMeta{Name: "some-grpc-svc", Namespace: "monitoring"},
			Spec:       corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 8428}}},
		}, false},
		{"label signal admits", corev1.Service{
			ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "apps", Labels: map[string]string{"app.kubernetes.io/name": "prometheus"}},
			Spec:       corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090}}},
		}, true},
		{"name signal admits", corev1.Service{
			ObjectMeta: metav1.ObjectMeta{Name: "team-prometheus", Namespace: "apps"},
			Spec:       corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090}}},
		}, true},
		{"port-name signal admits", corev1.Service{
			ObjectMeta: metav1.ObjectMeta{Name: "custom", Namespace: "apps"},
			Spec:       corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090, Name: "prometheus"}}},
		}, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, _, identity := ScoreService(tc.svc)
			if identity != tc.wantIdent {
				t.Fatalf("identity=%v, want %v", identity, tc.wantIdent)
			}
		})
	}
}

// A headless Service publishes A records for its ready pods under its own name.
// Addressing it 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 — and made a reachable
// backend look unreachable in-cluster.
func TestBuildClusterAddrUsesPlainServiceDNS(t *testing.T) {
	tests := []struct {
		name, namespace string
		port            int
		want            string
	}{
		// Headless kube-prometheus-stack Service: its pod is not named
		// prometheus-operated-0, so the old {service}-0. prefix never resolved.
		{"prometheus-operated", "monitoring", 9090, "http://prometheus-operated.monitoring.svc.cluster.local:9090"},
		// Headless caretta store: must still resolve under its own name.
		{"caretta-vm", "default", 8428, "http://caretta-vm.default.svc.cluster.local:8428"},
		// Regular ClusterIP Service: unchanged.
		{"kube-prometheus-stack-prometheus", "monitoring", 9090, "http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090"},
	}
	for _, tc := range tests {
		if got := buildClusterAddr(tc.name, tc.namespace, tc.port); got != tc.want {
			t.Errorf("buildClusterAddr(%s/%s) = %q, want %q", tc.namespace, tc.name, got, tc.want)
		}
	}
}

// discoverFixture is a cluster with every candidate tier: a well-known
// Prometheus, the caretta-vm fallback, more equal-score dynamic candidates
// than MaxDynamic keeps, and unrelated Services.
func discoverFixture() []*corev1.Service {
	svc := func(ns, name string, lbls map[string]string, port int32) *corev1.Service {
		return &corev1.Service{
			ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: lbls},
			Spec:       corev1.ServiceSpec{ClusterIP: "10.0.0.1", Ports: []corev1.ServicePort{{Port: port}}},
		}
	}
	promLabels := map[string]string{"app.kubernetes.io/name": "prometheus"}
	out := []*corev1.Service{
		svc("monitoring", "prometheus-server", nil, 9090),
		svc("caretta", "caretta-vm", nil, 8428),
		svc("default", "redis", nil, 6379),
		svc("kube-system", "kube-dns", nil, 53),
	}
	for _, ns := range []string{"team-g", "team-c", "team-a", "team-f", "team-e", "team-b", "team-d"} {
		out = append(out, svc(ns, "prom", promLabels, 9090))
	}
	return out
}

// TestDiscover_ServicesSourceMatchesAPI pins that enumerating from a cached
// Service set returns exactly what the API path returns, whatever order the
// cache hands Services back in, and makes no API calls.
func TestDiscover_ServicesSourceMatchesAPI(t *testing.T) {
	fixture := discoverFixture()
	objs := make([]runtime.Object, len(fixture))
	for i, s := range fixture {
		objs[i] = s
	}
	want, err := Discover(context.Background(), fake.NewSimpleClientset(objs...), DiscoverOptions{IncludeDynamic: true})
	if err != nil {
		t.Fatalf("API Discover: %v", err)
	}
	if len(want) != 7 {
		t.Fatalf("fixture should yield 1 well-known + 5 dynamic + caretta, got %d: %+v", len(want), want)
	}

	for _, order := range [][]int{{10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0}, {3, 7, 0, 9, 1, 10, 5, 2, 8, 4, 6}} {
		shuffled := make([]*corev1.Service, len(order))
		for i, j := range order {
			shuffled[i] = fixture[j]
		}
		api := fake.NewSimpleClientset()
		got, err := Discover(context.Background(), api, DiscoverOptions{
			IncludeDynamic: true,
			Services:       func() ([]*corev1.Service, bool) { return shuffled, true },
		})
		if err != nil {
			t.Fatalf("cached Discover: %v", err)
		}
		if !reflect.DeepEqual(got, want) {
			t.Fatalf("cached enumeration differs from API enumeration\n got: %+v\nwant: %+v", got, want)
		}
		if actions := api.Actions(); len(actions) != 0 {
			t.Fatalf("cached enumeration made %d API calls: %v", len(actions), actions)
		}
	}
}

// TestDiscover_ServicesSourceDeclinedUsesAPI pins the fallback: a source that
// cannot vouch for a complete view (ok=false) leaves enumeration to the API.
func TestDiscover_ServicesSourceDeclinedUsesAPI(t *testing.T) {
	api := fake.NewSimpleClientset(discoverFixture()[0])
	got, err := Discover(context.Background(), api, DiscoverOptions{
		IncludeDynamic: true,
		Services:       func() ([]*corev1.Service, bool) { return nil, false },
	})
	if err != nil {
		t.Fatalf("Discover: %v", err)
	}
	if len(got) != 1 || got[0].Name != "prometheus-server" {
		t.Fatalf("want the API's monitoring/prometheus-server, got %+v", got)
	}
	if len(api.Actions()) == 0 {
		t.Fatal("declined source should fall back to API calls")
	}
}

func TestIsCandidateService(t *testing.T) {
	fixture := discoverFixture()
	for _, tc := range []struct {
		svc  *corev1.Service
		want bool
	}{
		{fixture[0], true}, // well-known location, no labels
		{fixture[1], true}, // caretta-vm fallback location
		{fixture[2], false},
		{fixture[3], false},
		{fixture[4], true}, // identity label
		{nil, false},
	} {
		name := "nil"
		if tc.svc != nil {
			name = tc.svc.Namespace + "/" + tc.svc.Name
		}
		if got := IsCandidateService(tc.svc); got != tc.want {
			t.Errorf("IsCandidateService(%s) = %v, want %v", name, got, tc.want)
		}
	}
}