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
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
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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
}
186 changes: 186 additions & 0 deletions internal/managementrouter/query_filters_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package managementrouter

import (
"net/url"
"testing"
)

func TestParseStateLabelsAndMatchers(t *testing.T) {
tests := []struct {
name string
query string
wantState string
wantLabels map[string]string
wantMatchers []string
wantMatchersLen int
wantErr bool
}{
{
name: "empty query",
query: "",
wantState: "",
wantLabels: map[string]string{},
wantMatchers: nil,
},
{
name: "state only",
query: "state=firing",
wantState: "firing",
wantLabels: map[string]string{},
},
{
name: "flat labels only",
query: "severity=critical&namespace=openshift-monitoring",
wantState: "",
wantLabels: map[string]string{
"severity": "critical",
"namespace": "openshift-monitoring",
},
},
{
name: "match[] only with equality",
query: `match[]=severity="critical"`,
wantState: "",
wantLabels: map[string]string{},
wantMatchers: []string{
`severity="critical"`,
},
},
{
name: "match[] with regex",
query: `match[]=alertname=~"Kube.*CPU.*"`,
wantState: "",
wantLabels: map[string]string{},
wantMatchers: []string{
`alertname=~"Kube.*CPU.*"`,
},
},
{
name: "multiple match[] values",
query: `match[]=severity="critical"&match[]=namespace="openshift-monitoring"`,
wantState: "",
wantLabels: map[string]string{},
wantMatchersLen: 2,
},
{
name: "mixed flat labels and match[]",
query: `state=firing&team=sre&match[]=severity=~"critical|warning"`,
wantState: "firing",
wantLabels: map[string]string{
"team": "sre",
},
wantMatchers: []string{
`severity=~"critical|warning"`,
},
},
{
name: "match[] is not treated as a label",
query: `match[]=severity="critical"`,
wantState: "",
wantLabels: map[string]string{},
},
{
name: "invalid state",
query: "state=invalid",
wantErr: true,
},
{
name: "repeated state values are rejected",
query: "state=&state=firing",
wantErr: true,
},
{
name: "empty match[] values are skipped",
query: `match[]=&match[]=%20&match[]=severity="warning"`,
wantState: "",
wantLabels: map[string]string{},
wantMatchersLen: 1,
},
{
name: "repeated label values are rejected",
query: "severity=critical&severity=warning",
wantErr: true,
},
{
name: "repeated label with leading empty value is rejected",
query: "namespace=&namespace=ns1",
wantErr: true,
},
{
name: "invalid match[] is rejected",
query: `match[]=severity=`,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatalf("invalid test query: %v", err)
}

state, labels, matchers, err := parseStateLabelsAndMatchers(q)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if state != tt.wantState {
t.Errorf("state = %q, want %q", state, tt.wantState)
}

if tt.wantLabels != nil {
if len(labels) != len(tt.wantLabels) {
t.Errorf("labels length = %d, want %d", len(labels), len(tt.wantLabels))
}
for k, v := range tt.wantLabels {
if labels[k] != v {
t.Errorf("labels[%q] = %q, want %q", k, labels[k], v)
}
}
if _, found := labels["match[]"]; found {
t.Error("match[] should not appear in labels map")
}
}

if tt.wantMatchers != nil {
if len(matchers) != len(tt.wantMatchers) {
t.Errorf("matchers length = %d, want %d", len(matchers), len(tt.wantMatchers))
}
for i, want := range tt.wantMatchers {
if i < len(matchers) && matchers[i] != want {
t.Errorf("matchers[%d] = %q, want %q", i, matchers[i], want)
}
}
}

if tt.wantMatchersLen > 0 && len(matchers) != tt.wantMatchersLen {
t.Errorf("matchers length = %d, want %d", len(matchers), tt.wantMatchersLen)
}
})
}
}

func TestParseStateAndLabelsBackcompat(t *testing.T) {
q, _ := url.ParseQuery(`state=firing&severity=critical&match[]=alertname=~"Foo.*"`)

state, labels, err := parseStateAndLabels(q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if state != "firing" {
t.Errorf("state = %q, want %q", state, "firing")
}
if labels["severity"] != "critical" {
t.Errorf("severity = %q, want %q", labels["severity"], "critical")
}
if _, found := labels["match[]"]; found {
t.Error("match[] should not appear in labels map")
}
}
5 changes: 3 additions & 2 deletions internal/managementrouter/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ func New(managementClient management.Client) *mux.Router {
BaseURL: "/api/v1/alerting",
BaseRouter: r,
})
// GET /alerts is not yet in the OpenAPI spec; registered manually
// until its branch adds the spec entry and generated bindings.
// GET /alerts and GET /rules are not yet in the OpenAPI spec; registered
// manually until their respective branches add the spec entries.
r.HandleFunc("/api/v1/alerting/alerts", hr.GetAlerts).Methods(http.MethodGet)
r.HandleFunc("/api/v1/alerting/rules", hr.GetRules).Methods(http.MethodGet)

return r
}
Expand Down
Loading