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
46 changes: 39 additions & 7 deletions internal/managementrouter/query_filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,55 @@ 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,
"limit": true,
"next_token": true,
}

// parseStateLabelsAndMatchers returns the optional state filter, label equality
// matches, and Prometheus-style label matchers from the query string.
//
// Reserved keys ("state", "match[]") are handled specially. Every other key is
// treated as a label equality filter (e.g. ?severity=critical).
//
// 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) {
state := strings.ToLower(strings.TrimSpace(q.Get("state")))
if !validStates[state] {
return "", nil, fmt.Errorf("invalid state filter %q: must be one of pending, firing, silenced", q.Get("state"))
return "", nil, nil, fmt.Errorf("invalid state filter %q: must be one of pending, firing, silenced", q.Get("state"))
}

labels := make(map[string]string)
for key, vals := range q {
if key == "state" {
if reservedQueryKeys[key] {
continue
}
if len(vals) > 0 && strings.TrimSpace(vals[0]) != "" {
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)
}
}

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
}
166 changes: 166 additions & 0 deletions internal/managementrouter/query_filters_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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: "empty match[] values are skipped",
query: `match[]=&match[]=%20&match[]=severity="warning"`,
wantState: "",
wantLabels: map[string]string{},
wantMatchersLen: 1,
},
}

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 @@ -40,9 +40,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
48 changes: 48 additions & 0 deletions internal/managementrouter/rules_get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package managementrouter

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

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

type GetRulesResponse struct {
Data GetRulesResponseData `json:"data"`
Warnings []string `json:"warnings,omitempty"`
}

type GetRulesResponseData struct {
Groups []k8s.PrometheusRuleGroup `json:"groups"`
}

func (hr *httpRouter) GetRules(w http.ResponseWriter, req *http.Request) {
state, labels, matchers, err := parseStateLabelsAndMatchers(req.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := req.Context()

groups, err := hr.managementClient.GetRules(ctx, k8s.GetRulesRequest{
Labels: labels,
Matchers: matchers,
State: state,
})
if err != nil {
handleError(w, err)
return
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(GetRulesResponse{
Data: GetRulesResponseData{
Groups: groups,
},
Warnings: hr.rulesWarnings(ctx),
}); err != nil {
log.WithError(err).Warn("failed to encode rules response")
}
}
5 changes: 5 additions & 0 deletions pkg/k8s/prometheus_rules_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import (
"time"
)

const (
RuleTypeAlerting = "alerting"
RuleTypeRecording = "recording"
)

// GetRulesRequest holds parameters for filtering rules alerts.
type GetRulesRequest struct {
// Labels filters rules by exact label equality. The special key "namespace"
Expand Down
Loading