Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ require (
github.com/openshift/library-go v0.0.0-20240905123346-5bdbfe35a6f5
github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.87.0
github.com/prometheus-operator/prometheus-operator/pkg/client v0.87.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.67.4
github.com/prometheus/prometheus v0.308.0
github.com/sirupsen/logrus v1.9.3
Expand Down Expand Up @@ -57,8 +59,6 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // indirect
Expand Down
49 changes: 0 additions & 49 deletions internal/managementrouter/alerts_get.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package managementrouter

import (
"context"
"encoding/json"
"net/http"

Expand Down Expand Up @@ -46,51 +45,3 @@ func (hr *httpRouter) GetAlerts(w http.ResponseWriter, req *http.Request) {
log.WithError(err).Warn("failed to encode alerts response")
}
}

//nolint:unused // used by the rules listing handler in a subsequent branch
func (hr *httpRouter) rulesWarnings(ctx context.Context) []string {
health, ok := hr.alertingHealth(ctx)
if !ok {
return nil
}

if health.UserWorkloadEnabled && health.UserWorkload != nil {
return buildRouteWarnings(health.UserWorkload.Prometheus, k8s.UserWorkloadRouteName, "user workload Prometheus")
}

return nil
}

//nolint:unused // called by rulesWarnings, used in a subsequent branch
func (hr *httpRouter) alertingHealth(ctx context.Context) (k8s.AlertingHealth, bool) {
if hr.managementClient == nil {
return k8s.AlertingHealth{}, false
}

health, err := hr.managementClient.GetAlertingHealth(ctx)
if err != nil {
log.WithError(err).Warn("alerting health unavailable")
return k8s.AlertingHealth{}, false
}

return health, true
}

//nolint:unused // called by rulesWarnings, used in a subsequent branch
func buildRouteWarnings(route k8s.AlertingRouteHealth, expectedName string, friendlyName string) []string {
if route.Name != "" && route.Name != expectedName {
return nil
}
if route.FallbackReachable {
return nil
}

switch route.Status {
case k8s.RouteNotFound:
return []string{friendlyName + " route is missing"}
case k8s.RouteUnreachable:
return []string{friendlyName + " route is unreachable"}
default:
return nil
}
}
8 changes: 8 additions & 0 deletions internal/managementrouter/alerts_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ func decodeAlertsResp(t *testing.T, w *httptest.ResponseRecorder) managementrout
return resp
}

func TestGetAlerts_RepeatedStateRejected(t *testing.T) {
f := newAGFixture(t)
w := f.get(t, "/api/v1/alerting/alerts?state=&state=firing")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
}
}

func TestGetAlerts_ParsesFlatQueryParams(t *testing.T) {
f := newAGFixture(t)
var captured k8s.GetAlertsRequest
Expand Down
33 changes: 33 additions & 0 deletions internal/managementrouter/health_get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package managementrouter

import (
"encoding/json"
"net/http"

"github.com/openshift/monitoring-plugin/pkg/k8s"
)

type GetHealthResponse struct {
Alerting *k8s.AlertingHealth `json:"alerting,omitempty"`
}

// GetHealth serves GET /api/v1/alerting/health.
func (hr *httpRouter) GetHealth(w http.ResponseWriter, req *http.Request) {
resp := GetHealthResponse{}

if hr.managementClient != nil {
health, err := hr.managementClient.GetAlertingHealth(req.Context())
if err != nil {
handleError(w, err)
return
}
resp.Alerting = &health
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.WithError(err).Warn("failed to encode health response")
}
}
93 changes: 93 additions & 0 deletions internal/managementrouter/health_get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package managementrouter_test

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/openshift/monitoring-plugin/internal/managementrouter"
"github.com/openshift/monitoring-plugin/pkg/k8s"
)

func sampleAlertingHealth() k8s.AlertingHealth {
return k8s.AlertingHealth{
Platform: &k8s.AlertingStackHealth{
Prometheus: k8s.AlertingRouteHealth{Name: "prometheus-k8s", Namespace: "openshift-monitoring", Status: k8s.RouteReachable},
Alertmanager: k8s.AlertingRouteHealth{Name: "alertmanager-main", Namespace: "openshift-monitoring", Status: k8s.RouteReachable},
},
UserWorkloadEnabled: true,
UserWorkload: &k8s.AlertingStackHealth{
Prometheus: k8s.AlertingRouteHealth{Name: "prometheus-user-workload", Namespace: "openshift-user-workload-monitoring", Status: k8s.RouteReachable},
Alertmanager: k8s.AlertingRouteHealth{Name: "alertmanager-user-workload", Namespace: "openshift-user-workload-monitoring", Status: k8s.RouteReachable},
},
}
}

func TestGetHealth_Returns200(t *testing.T) {
f := newAGFixture(t)
f.mockK8s.AlertingHealthFunc = func(_ context.Context) (k8s.AlertingHealth, error) {
return sampleAlertingHealth(), nil
}

w := f.get(t, "/api/v1/alerting/health")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
}

func TestGetHealth_ReturnsAlertingStructure(t *testing.T) {
f := newAGFixture(t)
f.mockK8s.AlertingHealthFunc = func(_ context.Context) (k8s.AlertingHealth, error) {
return sampleAlertingHealth(), nil
}

w := f.get(t, "/api/v1/alerting/health")
var response managementrouter.GetHealthResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode error: %v", err)
}
if response.Alerting == nil {
t.Fatal("expected non-nil Alerting in response")
}
if response.Alerting.Platform == nil || response.Alerting.Platform.Prometheus.Name != "prometheus-k8s" {
t.Errorf("expected platform prometheus-k8s, got %+v", response.Alerting.Platform)
}
if !response.Alerting.UserWorkloadEnabled {
t.Error("expected UserWorkloadEnabled=true")
}
if response.Alerting.UserWorkload == nil || response.Alerting.UserWorkload.Prometheus.Name != "prometheus-user-workload" {
t.Errorf("expected user workload prometheus-user-workload, got %+v", response.Alerting.UserWorkload)
}
}

func TestGetHealth_Returns500OnError(t *testing.T) {
f := newAGFixture(t)
f.mockK8s.AlertingHealthFunc = func(_ context.Context) (k8s.AlertingHealth, error) {
return k8s.AlertingHealth{}, fmt.Errorf("connection refused")
}

w := f.get(t, "/api/v1/alerting/health")
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body)
}
if body := w.Body.String(); !strings.Contains(body, "An unexpected error occurred") {
t.Errorf("expected error message, got: %s", body)
}
}

func TestGetHealth_MissingAuthHeaderReturns401(t *testing.T) {
f := newAGFixture(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/alerting/health", nil)
w := httptest.NewRecorder()
f.router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body)
}
}
60 changes: 50 additions & 10 deletions internal/managementrouter/query_filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"net/url"
"strings"

"github.com/openshift/monitoring-plugin/pkg/k8s"
)

var validStates = map[string]bool{
Expand All @@ -12,27 +14,65 @@ var validStates = map[string]bool{
"silenced": true,
}

// parseStateAndLabels returns the optional state filter and label matches.
// Any query param other than "state" is treated as a label match.
// Returns an error if the state value is not one of the known states.
func parseStateAndLabels(q url.Values) (string, map[string]string, error) {
// reservedQueryKeys lists query parameter names that have special meaning
// and must not be treated as label equality filters.
var reservedQueryKeys = map[string]bool{
"state": true,
"match[]": true,
}

// parseStateLabelsAndMatchers returns the optional state filter, label equality
// matches, and Prometheus-style label matchers from the query string.
//
// An empty state is allowed and means "all states". Repeated state values
// are rejected. Reserved keys ("state", "match[]") are handled specially.
// Every other key is treated as a label equality filter
// (e.g. ?severity=critical). Repeated values for a label key are rejected.
//
// match[] values follow upstream Prometheus API conventions and may contain
// equality, inequality, regex, or negative-regex matchers:
//
// ?match[]=severity="critical"&match[]=alertname=~"Kube.*"
func parseStateLabelsAndMatchers(q url.Values) (string, map[string]string, []string, error) {
if len(q["state"]) > 1 {
return "", nil, nil, fmt.Errorf("multiple values for state filter: only a single value is supported")
}
state := strings.ToLower(strings.TrimSpace(q.Get("state")))
if state != "" && !validStates[state] {
return "", nil, fmt.Errorf("invalid state filter %q: must be one of pending, firing, silenced", state)
return "", nil, nil, fmt.Errorf("invalid state filter %q: must be one of pending, firing, silenced", state)
}

labels := make(map[string]string)
for key, vals := range q {
if key == "state" {
if reservedQueryKeys[key] {
continue
}
if len(vals) > 1 {
return "", nil, nil, fmt.Errorf("multiple values for label filter %q: only a single value is supported", key)
}
if len(vals) == 0 || strings.TrimSpace(vals[0]) == "" {
continue
}
if len(vals) > 1 {
return "", nil, fmt.Errorf("multiple values for label filter %q: only a single value is supported", key)
}
labels[strings.TrimSpace(key)] = strings.TrimSpace(vals[0])
}
return state, labels, nil

var matchers []string
for _, raw := range q["match[]"] {
v := strings.TrimSpace(raw)
if v != "" {
matchers = append(matchers, v)
}
}
if err := k8s.ParseRuleMatchers(matchers); err != nil {
return "", nil, nil, err
}

return state, labels, matchers, nil
}

// parseStateAndLabels returns the optional state filter and label matches.
// Any query param other than reserved keys is treated as a label match.
func parseStateAndLabels(q url.Values) (string, map[string]string, error) {
state, labels, _, err := parseStateLabelsAndMatchers(q)
return state, labels, err
}
Loading