diff --git a/src/core/alertmanager.go b/src/core/alertmanager.go index 41a589ea..7793933d 100644 --- a/src/core/alertmanager.go +++ b/src/core/alertmanager.go @@ -270,7 +270,7 @@ func (s *alertmanagerService) DeleteSilence(silenceID string) error { } func alertmanagerUrl(config cfg.ConfigModule) (string, error) { - namespace, serviceName, port, err := kubernetes.FindAlertmanagerService() + namespace, serviceName, port, basePath, err := kubernetes.FindAlertmanagerService() if err != nil { return "", err } @@ -279,5 +279,5 @@ func alertmanagerUrl(config cfg.ConfigModule) (string, error) { clusterDomain = "cluster.local" } - return fmt.Sprintf("http://%s.%s.svc.%s:%d", serviceName, namespace, clusterDomain, port), nil + return fmt.Sprintf("http://%s.%s.svc.%s:%d%s", serviceName, namespace, clusterDomain, port, basePath), nil } diff --git a/src/core/prometheus.go b/src/core/prometheus.go index 8104523c..9bafd534 100644 --- a/src/core/prometheus.go +++ b/src/core/prometheus.go @@ -260,7 +260,7 @@ func prometheusUrlAndHeader(data PrometheusRequest, customEndpoint string, confi result := data.Prometheus_API_URL if data.Prometheus_API_URL == "" { - namespace, serviceName, port, err := kubernetes.FindPrometheusService() + namespace, serviceName, port, basePath, err := kubernetes.FindPrometheusService() if err != nil { logger.Warn("prometheus service auto-discovery failed; no prometheus URL was provided either", "error", err) return "", header @@ -270,7 +270,7 @@ func prometheusUrlAndHeader(data PrometheusRequest, customEndpoint string, confi clusterDomain = "cluster.local" } - result = fmt.Sprintf("http://%s.%s.svc.%s:%d", serviceName, namespace, clusterDomain, port) + result = fmt.Sprintf("http://%s.%s.svc.%s:%d%s", serviceName, namespace, clusterDomain, port, basePath) logger.Debug("prometheus auto-discovered", "service", serviceName, "namespace", namespace, "port", port, "url", result) } diff --git a/src/kubernetes/service.go b/src/kubernetes/service.go index aca3937e..79ccc59d 100644 --- a/src/kubernetes/service.go +++ b/src/kubernetes/service.go @@ -5,6 +5,7 @@ import ( cfg "mogenius-operator/src/config" "mogenius-operator/src/store" "mogenius-operator/src/utils" + "sort" "strings" "time" @@ -33,41 +34,63 @@ func AllServices(namespaceName string) []v1.Service { return result } -// prometheusNames lists the well-known Prometheus service names in priority -// order. The first name that matches a service in the cluster wins, so the -// canonical kube-prometheus-stack names are checked before the generic ones. -var prometheusNames = []string{ - "prometheus-kube-prometheus-prometheus", - "kube-prometheus-stack-prometheus", - "prometheus-server", - "prometheus-service", - "prometheus", - "prometheus-prometheus-server", +// serviceCandidate describes how to recognize a well-known monitoring service: +// by exact name, or by name prefix for operator-managed services whose names +// embed the Helm release name (e.g. VictoriaMetrics' vmsingle-). +// basePath is appended to the discovered service URL when the Prometheus API +// is not served at the root of the service. +type serviceCandidate struct { + name string + prefix bool + basePath string } -// alertmanagerNames lists the well-known Alertmanager service names in priority order. -var alertmanagerNames = []string{ - "alertmanager-kube-prometheus-alertmanager", - "kube-prometheus-stack-alertmanager", - "alertmanager-service", - "alertmanager", - "prometheus-alertmanager", +// prometheusCandidates lists the well-known Prometheus-compatible services in +// priority order. The first candidate that matches a service in the cluster +// wins, so the canonical kube-prometheus-stack names are checked before the +// generic ones, and VictoriaMetrics single-node before cluster mode. +var prometheusCandidates = []serviceCandidate{ + {name: "prometheus-kube-prometheus-prometheus"}, + {name: "kube-prometheus-stack-prometheus"}, + {name: "prometheus-server"}, + {name: "prometheus-service"}, + {name: "prometheus"}, + {name: "prometheus-prometheus-server"}, + // VictoriaMetrics (victoria-metrics-k8s-stack) single-node: serves the + // Prometheus query API at the service root. + {name: "vmsingle-", prefix: true}, + // VictoriaMetrics cluster mode: vmselect serves the Prometheus query API + // under the tenant path (tenant 0 is the default single-tenant setup). + {name: "vmselect-", prefix: true, basePath: "/select/0/prometheus"}, } -func FindPrometheusService() (namespace string, service string, port int32, err error) { - return findServiceByPriority(prometheusNames, "prometheus") +// alertmanagerCandidates lists the well-known Alertmanager services in priority order. +var alertmanagerCandidates = []serviceCandidate{ + {name: "alertmanager-kube-prometheus-alertmanager"}, + {name: "kube-prometheus-stack-alertmanager"}, + {name: "alertmanager-service"}, + {name: "alertmanager"}, + {name: "prometheus-alertmanager"}, + // VictoriaMetrics (victoria-metrics-k8s-stack) managed Alertmanager. + {name: "vmalertmanager-", prefix: true}, } -func FindAlertmanagerService() (namespace string, service string, port int32, err error) { - return findServiceByPriority(alertmanagerNames, "alertmanager") +func FindPrometheusService() (namespace string, service string, port int32, basePath string, err error) { + return matchServiceByPriority(prometheusCandidates, "prometheus") } -// findServiceByPriority returns the first cluster service whose name matches one -// of candidateNames, evaluated in the order given. The matched service's +func FindAlertmanagerService() (namespace string, service string, port int32, basePath string, err error) { + return matchServiceByPriority(alertmanagerCandidates, "alertmanager") +} + +// matchServiceByPriority returns the first service whose name matches one of +// the candidates, evaluated in the order given. The matched service's // HTTP/web port is preferred over an arbitrary first port (see selectHTTPPort). -func findServiceByPriority(candidateNames []string, kind string) (namespace string, service string, port int32, err error) { +func matchServiceByPriority(candidates []serviceCandidate, kind string) (namespace string, service string, port int32, basePath string, err error) { byName := make(map[string]v1.Service) - for _, svc := range clusterServicesCached.Get() { + services := clusterServicesCached.Get() + names := make([]string, 0, len(services)) + for _, svc := range services { if len(svc.Spec.Ports) == 0 { continue } @@ -75,15 +98,28 @@ func findServiceByPriority(candidateNames []string, kind string) (namespace stri // service name exists in multiple namespaces. if _, seen := byName[svc.Name]; !seen { byName[svc.Name] = svc + names = append(names, svc.Name) } } + // Prefix candidates can match several services; pick the lexicographically + // smallest name so discovery is deterministic across restarts. + sort.Strings(names) - for _, name := range candidateNames { - if svc, ok := byName[name]; ok { - return svc.Namespace, svc.Name, selectHTTPPort(svc.Spec.Ports), nil + for _, c := range candidates { + if !c.prefix { + if svc, ok := byName[c.name]; ok { + return svc.Namespace, svc.Name, selectHTTPPort(svc.Spec.Ports), c.basePath, nil + } + continue + } + for _, name := range names { + if strings.HasPrefix(name, c.name) { + svc := byName[name] + return svc.Namespace, svc.Name, selectHTTPPort(svc.Spec.Ports), c.basePath, nil + } } } - return "", "", -1, fmt.Errorf("%s service not found in any namespace", kind) + return "", "", -1, "", fmt.Errorf("%s service not found in any namespace", kind) } // selectHTTPPort picks the most likely HTTP API port from a service's ports: diff --git a/src/kubernetes/service_test.go b/src/kubernetes/service_test.go index 3dfbfa2d..f1bd26ad 100644 --- a/src/kubernetes/service_test.go +++ b/src/kubernetes/service_test.go @@ -2,10 +2,131 @@ package kubernetes import ( "testing" + "time" + + "mogenius-operator/src/utils" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func makeService(namespace, name string, ports ...v1.ServicePort) v1.Service { + return v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1.ServiceSpec{Ports: ports}, + } +} + +func TestMatchServiceByPriority(t *testing.T) { + tests := []struct { + name string + services []v1.Service + candidates []serviceCandidate + wantService string + wantPort int32 + wantBasePath string + wantErr bool + }{ + { + name: "kube-prometheus-stack exact name wins over VictoriaMetrics", + services: []v1.Service{ + makeService("vm", "vmsingle-vm-stack", v1.ServicePort{Name: "http", Port: 8429}), + makeService("monitoring", "kube-prometheus-stack-prometheus", v1.ServicePort{Name: "http-web", Port: 9090}), + }, + candidates: prometheusCandidates, + wantService: "kube-prometheus-stack-prometheus", + wantPort: 9090, + }, + { + name: "victoria-metrics single-node matched by prefix", + services: []v1.Service{ + makeService("vm", "vmagent-vm-stack", v1.ServicePort{Name: "http", Port: 8429}), + makeService("vm", "vmsingle-vm-stack", v1.ServicePort{Name: "http", Port: 8429}), + }, + candidates: prometheusCandidates, + wantService: "vmsingle-vm-stack", + wantPort: 8429, + }, + { + name: "victoria-metrics cluster mode gets the vmselect tenant base path", + services: []v1.Service{ + makeService("vm", "vminsert-vm-stack", v1.ServicePort{Name: "http", Port: 8480}), + makeService("vm", "vmselect-vm-stack", v1.ServicePort{Name: "http", Port: 8481}), + }, + candidates: prometheusCandidates, + wantService: "vmselect-vm-stack", + wantPort: 8481, + wantBasePath: "/select/0/prometheus", + }, + { + name: "vmsingle preferred over vmselect when both exist", + services: []v1.Service{ + makeService("vm", "vmselect-vm-stack", v1.ServicePort{Name: "http", Port: 8481}), + makeService("vm", "vmsingle-vm-stack", v1.ServicePort{Name: "http", Port: 8429}), + }, + candidates: prometheusCandidates, + wantService: "vmsingle-vm-stack", + wantPort: 8429, + }, + { + name: "victoria-metrics alertmanager matched by prefix", + services: []v1.Service{ + makeService("vm", "vmalertmanager-vm-stack", v1.ServicePort{Name: "http", Port: 9093}), + }, + candidates: alertmanagerCandidates, + wantService: "vmalertmanager-vm-stack", + wantPort: 9093, + }, + { + name: "multiple prefix matches pick the lexicographically smallest name", + services: []v1.Service{ + makeService("vm-b", "vmsingle-stack-b", v1.ServicePort{Name: "http", Port: 8429}), + makeService("vm-a", "vmsingle-stack-a", v1.ServicePort{Name: "http", Port: 8429}), + }, + candidates: prometheusCandidates, + wantService: "vmsingle-stack-a", + wantPort: 8429, + }, + { + name: "services without ports are ignored", + services: []v1.Service{ + makeService("vm", "vmsingle-vm-stack"), + }, + candidates: prometheusCandidates, + wantErr: true, + }, + { + name: "no match returns an error", + services: []v1.Service{makeService("default", "kubernetes", v1.ServicePort{Name: "https", Port: 443})}, + candidates: prometheusCandidates, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + orig := clusterServicesCached + clusterServicesCached = utils.NewTTLCache(time.Minute, func() []v1.Service { return tt.services }) + t.Cleanup(func() { clusterServicesCached = orig }) + + _, service, port, basePath, err := matchServiceByPriority(tt.candidates, "prometheus") + if tt.wantErr { + if err == nil { + t.Fatalf("matchServiceByPriority() expected error, got service %q", service) + } + return + } + if err != nil { + t.Fatalf("matchServiceByPriority() unexpected error: %v", err) + } + if service != tt.wantService || port != tt.wantPort || basePath != tt.wantBasePath { + t.Errorf("matchServiceByPriority() = (%q, %d, %q), want (%q, %d, %q)", + service, port, basePath, tt.wantService, tt.wantPort, tt.wantBasePath) + } + }) + } +} + func TestSelectHTTPPort(t *testing.T) { tests := []struct { name string