diff --git a/README.md b/README.md index 17412ff4..e5018c88 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,11 @@ NOTE: When the `/api/v1/labels` and `/api/v1/label//values` endpoints were ### Rules endpoint -The proxy requests the `/api/v1/rules` Prometheus endpoint, discards the rules that don't contain an exact match of the label and returns the modified response to the client. +The proxy requests the `/api/v1/rules` Prometheus endpoint, discards the rules that don't contain an exact match of the label(s) and returns the modified response to the client. ### Alerts endpoint -The proxy requests the `/api/v1/alerts` Prometheus endpoint, discards the rules that don't contain an exact match of the label and returns the modified response to the client. +The proxy requests the `/api/v1/alerts` Prometheus endpoint, discards the rules that don't contain an exact match of the label(s) and returns the modified response to the client. ### Silences endpoint diff --git a/go.mod b/go.mod index 502fccfb..cd529aa1 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/prometheus/alertmanager v0.27.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/prometheus v0.52.1 - golang.org/x/exp v0.0.0-20240119083558-1b970713d09a gotest.tools/v3 v3.5.1 ) diff --git a/injectproxy/routes.go b/injectproxy/routes.go index f851e073..6d1ef38e 100644 --- a/injectproxy/routes.go +++ b/injectproxy/routes.go @@ -378,6 +378,9 @@ func NewRoutes(upstream *url.URL, label string, extractLabeler ExtractLabeler, o "/api/v1/rules": modifyAPIResponse(r.filterRules), "/api/v1/alerts": modifyAPIResponse(r.filterAlerts), } + //FIXME: when ModifyResponse returns an error, the default ErrorHandler is + //called which returns 502 Bad Gateway. It'd be more appropriate to treat + //the error and return 400 in case of bad input for instance. proxy.ModifyResponse = r.ModifyResponse return r, nil } @@ -577,6 +580,46 @@ func enforceQueryValues(e *PromQLEnforcer, v url.Values) (values string, noQuery return v.Encode(), true, nil } +func (r *routes) newLabelMatcher(vals ...string) (*labels.Matcher, error) { + if r.regexMatch { + if len(vals) != 1 { + return nil, errors.New("only one label value allowed with regex match") + } + + re := vals[0] + compiledRegex, err := regexp.Compile(re) + if err != nil { + return nil, fmt.Errorf("invalid regex: %w", err) + } + + if compiledRegex.MatchString("") { + return nil, errors.New("regex should not match empty string") + } + + m, err := labels.NewMatcher(labels.MatchRegexp, r.label, re) + if err != nil { + return nil, err + } + + return m, nil + } + + if len(vals) == 1 { + return &labels.Matcher{ + Name: r.label, + Type: labels.MatchEqual, + Value: vals[0], + }, nil + } + + m, err := labels.NewMatcher(labels.MatchRegexp, r.label, labelValuesToRegexpString(vals)) + if err != nil { + return nil, err + } + + return m, nil +} + // matcher modifies all the match[] HTTP parameters to match on the tenant label. // If none was provided, a tenant label matcher matcher is injected. // This works for non-query Prometheus API endpoints like /api/v1/series, @@ -584,14 +627,15 @@ func enforceQueryValues(e *PromQLEnforcer, v url.Values) (values string, noQuery // multiple matchers. // See e.g https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metadata func (r *routes) matcher(w http.ResponseWriter, req *http.Request) { - matcher := &labels.Matcher{ - Name: r.label, - Type: labels.MatchRegexp, - Value: labelValuesToRegexpString(MustLabelValues(req.Context())), + matcher, err := r.newLabelMatcher(MustLabelValues(req.Context())...) + if err != nil { + prometheusAPIError(w, err.Error(), http.StatusBadRequest) + return } q := req.URL.Query() if err := injectMatcher(q, matcher); err != nil { + prometheusAPIError(w, err.Error(), http.StatusBadRequest) return } diff --git a/injectproxy/routes_test.go b/injectproxy/routes_test.go index ead8e241..e24716b5 100644 --- a/injectproxy/routes_test.go +++ b/injectproxy/routes_test.go @@ -34,6 +34,7 @@ func checkParameterAbsent(param string, next http.Handler) http.Handler { prometheusAPIError(w, fmt.Sprintf("unexpected error: %v", err), http.StatusInternalServerError) return } + if len(kvs[param]) != 0 { prometheusAPIError(w, fmt.Sprintf("unexpected parameter %q", param), http.StatusInternalServerError) return @@ -264,6 +265,7 @@ func TestMatch(t *testing.T) { for _, tc := range []struct { labelv []string matches []string + opts []Option expCode int expMatch []string @@ -277,7 +279,7 @@ func TestMatch(t *testing.T) { // No "match" parameter. labelv: []string{"default"}, expCode: http.StatusOK, - expMatch: []string{`{namespace=~"default"}`}, + expMatch: []string{`{namespace="default"}`}, expBody: okResponse, }, { @@ -285,7 +287,7 @@ func TestMatch(t *testing.T) { labelv: []string{"default"}, matches: []string{`{job="prometheus",__name__=~"job:.*"}`}, expCode: http.StatusOK, - expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace=~"default"}`}, + expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace="default"}`}, expBody: okResponse, }, { @@ -309,7 +311,7 @@ func TestMatch(t *testing.T) { labelv: []string{"default"}, matches: []string{`{job="prometheus",__name__=~"job:.*",namespace="default"}`}, expCode: http.StatusOK, - expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace="default",namespace=~"default"}`}, + expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace="default",namespace="default"}`}, expBody: okResponse, }, { @@ -317,7 +319,7 @@ func TestMatch(t *testing.T) { labelv: []string{"default"}, matches: []string{`{job="prometheus"}`, `{__name__=~"job:.*"}`}, expCode: http.StatusOK, - expMatch: []string{`{job="prometheus",namespace=~"default"}`, `{__name__=~"job:.*",namespace=~"default"}`}, + expMatch: []string{`{job="prometheus",namespace="default"}`, `{__name__=~"job:.*",namespace="default"}`}, expBody: okResponse, }, { @@ -336,6 +338,42 @@ func TestMatch(t *testing.T) { }, expBody: okResponse, }, + { + // Many "match" parameters with a single regex value. + labelv: []string{".+-monitoring"}, + matches: []string{ + `{job="prometheus"}`, + `{__name__=~"job:.*"}`, + `{namespace="something"}`, + }, + opts: []Option{WithRegexMatch()}, + + expCode: http.StatusOK, + expMatch: []string{ + `{job="prometheus",namespace=~".+-monitoring"}`, + `{__name__=~"job:.*",namespace=~".+-monitoring"}`, + `{namespace="something",namespace=~".+-monitoring"}`, + }, + expBody: okResponse, + }, + { + // A single "match" parameter with multiple regex values. + labelv: []string{"default", "something"}, + matches: []string{ + `{job="prometheus"}`, + }, + opts: []Option{WithRegexMatch()}, + expCode: http.StatusBadRequest, + }, + { + // A single "match" parameter with a regex value matching the empty string. + labelv: []string{".*"}, + matches: []string{ + `{job="prometheus"}`, + }, + opts: []Option{WithRegexMatch()}, + expCode: http.StatusBadRequest, + }, } { for _, u := range []string{ "http://prometheus.example.com/federate", @@ -351,7 +389,12 @@ func TestMatch(t *testing.T) { ) defer m.Close() - r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}, WithEnabledLabelsAPI()) + r, err := NewRoutes( + m.url, + proxyLabel, + HTTPFormEnforcer{ParameterName: proxyLabel}, + append([]Option{WithEnabledLabelsAPI()}, tc.opts...)..., + ) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -382,6 +425,7 @@ func TestMatch(t *testing.T) { t.Logf("%s", string(body)) t.FailNow() } + if resp.StatusCode != http.StatusOK { return } @@ -411,7 +455,7 @@ func TestMatchWithPost(t *testing.T) { // No "match" parameter. labelv: []string{"default"}, expCode: http.StatusOK, - expMatch: []string{`{namespace=~"default"}`}, + expMatch: []string{`{namespace="default"}`}, expBody: okResponse, }, { @@ -419,7 +463,7 @@ func TestMatchWithPost(t *testing.T) { labelv: []string{"default"}, matches: []string{`{job="prometheus",__name__=~"job:.*"}`}, expCode: http.StatusOK, - expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace=~"default"}`}, + expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace="default"}`}, expBody: okResponse, }, { @@ -443,7 +487,7 @@ func TestMatchWithPost(t *testing.T) { labelv: []string{"default"}, matches: []string{`{job="prometheus",__name__=~"job:.*",namespace="default"}`}, expCode: http.StatusOK, - expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace="default",namespace=~"default"}`}, + expMatch: []string{`{job="prometheus",__name__=~"job:.*",namespace="default",namespace="default"}`}, expBody: okResponse, }, { @@ -451,7 +495,7 @@ func TestMatchWithPost(t *testing.T) { labelv: []string{"default"}, matches: []string{`{job="prometheus"}`, `{__name__=~"job:.*"}`}, expCode: http.StatusOK, - expMatch: []string{`{job="prometheus",namespace=~"default"}`, `{__name__=~"job:.*",namespace=~"default"}`}, + expMatch: []string{`{job="prometheus",namespace="default"}`, `{__name__=~"job:.*",namespace="default"}`}, expBody: okResponse, }, { @@ -546,14 +590,14 @@ func TestSeries(t *testing.T) { { name: `No "match[]" parameter returns 200 with empty body`, labelv: []string{"default"}, - expMatch: []string{`{namespace=~"default"}`}, + expMatch: []string{`{namespace="default"}`}, expResponse: okResponse, expCode: http.StatusOK, }, { name: `No "match[]" parameter returns 200 with empty body for POSTs`, labelv: []string{"default"}, - expMatch: []string{`{namespace=~"default"}`}, + expMatch: []string{`{namespace="default"}`}, expResponse: okResponse, expCode: http.StatusOK, }, @@ -562,7 +606,7 @@ func TestSeries(t *testing.T) { labelv: []string{"default"}, promQuery: "up", expCode: http.StatusOK, - expMatch: []string{`{__name__="up",namespace=~"default"}`}, + expMatch: []string{`{__name__="up",namespace="default"}`}, expResponse: okResponse, }, { @@ -586,7 +630,7 @@ func TestSeries(t *testing.T) { labelv: []string{"default"}, promQuery: `up{instance="localhost:9090"}`, expCode: http.StatusOK, - expMatch: []string{`{instance="localhost:9090",__name__="up",namespace=~"default"}`}, + expMatch: []string{`{instance="localhost:9090",__name__="up",namespace="default"}`}, expResponse: okResponse, }, { @@ -679,7 +723,7 @@ func TestSeriesWithPost(t *testing.T) { name: `No "match[]" parameter returns 200 with empty body`, labelv: []string{"default"}, method: http.MethodPost, - expMatch: []string{`{namespace=~"default"}`}, + expMatch: []string{`{namespace="default"}`}, expResponse: okResponse, expCode: http.StatusOK, }, @@ -687,7 +731,7 @@ func TestSeriesWithPost(t *testing.T) { name: `No "match[]" parameter returns 200 with empty body for POSTs`, method: http.MethodPost, labelv: []string{"default"}, - expMatch: []string{`{namespace=~"default"}`}, + expMatch: []string{`{namespace="default"}`}, expResponse: okResponse, expCode: http.StatusOK, }, @@ -697,7 +741,7 @@ func TestSeriesWithPost(t *testing.T) { promQueryBody: "up", method: http.MethodPost, expCode: http.StatusOK, - expMatch: []string{`{__name__="up",namespace=~"default"}`}, + expMatch: []string{`{__name__="up",namespace="default"}`}, expResponse: okResponse, }, { @@ -724,7 +768,7 @@ func TestSeriesWithPost(t *testing.T) { promQueryBody: `up{instance="localhost:9090"}`, method: http.MethodPost, expCode: http.StatusOK, - expMatch: []string{`{instance="localhost:9090",__name__="up",namespace=~"default"}`}, + expMatch: []string{`{instance="localhost:9090",__name__="up",namespace="default"}`}, expResponse: okResponse, }, { diff --git a/injectproxy/rules.go b/injectproxy/rules.go index be5bc39b..ef93cb96 100644 --- a/injectproxy/rules.go +++ b/injectproxy/rules.go @@ -23,7 +23,6 @@ import ( "time" "github.com/prometheus/prometheus/model/labels" - "golang.org/x/exp/slices" ) type apiResponse struct { @@ -210,14 +209,20 @@ func (r *routes) filterRules(lvalues []string, resp *apiResponse) (interface{}, return nil, fmt.Errorf("can't decode rules data: %w", err) } + m, err := r.newLabelMatcher(lvalues...) + if err != nil { + return nil, err + } + filtered := []*ruleGroup{} for _, rg := range rgs.RuleGroups { var rules []rule for _, rule := range rg.Rules { - if lval := rule.Labels().Get(r.label); lval != "" && slices.Contains(lvalues, lval) { + if lval := rule.Labels().Get(r.label); lval != "" && m.Matches(lval) { rules = append(rules, rule) } } + if len(rules) > 0 { rg.Rules = rules filtered = append(filtered, rg) @@ -233,9 +238,14 @@ func (r *routes) filterAlerts(lvalues []string, resp *apiResponse) (interface{}, return nil, fmt.Errorf("can't decode alerts data: %w", err) } + m, err := r.newLabelMatcher(lvalues...) + if err != nil { + return nil, err + } + filtered := []*alert{} for _, alert := range data.Alerts { - if lval := alert.Labels.Get(r.label); lval != "" && slices.Contains(lvalues, lval) { + if lval := alert.Labels.Get(r.label); lval != "" && m.Matches(lval) { filtered = append(filtered, alert) } } diff --git a/injectproxy/rules_test.go b/injectproxy/rules_test.go index f325e1fc..421d278d 100644 --- a/injectproxy/rules_test.go +++ b/injectproxy/rules_test.go @@ -365,6 +365,7 @@ func TestRules(t *testing.T) { labelv []string upstream http.Handler reqHeaders http.Header + opts []Option expCode int golden string @@ -455,11 +456,32 @@ func TestRules(t *testing.T) { expCode: http.StatusOK, golden: "rules_match_namespaces_ns1_and_ns2.golden", }, + { + labelv: []string{"ns1|ns2"}, + upstream: validRules(), + opts: []Option{WithRegexMatch()}, + + expCode: http.StatusOK, + golden: "rules_match_namespaces_ns1_and_ns2.golden", + }, + { + labelv: []string{"ns1|ns2", "ns3"}, + upstream: validRules(), + opts: []Option{WithRegexMatch()}, + + expCode: http.StatusBadGateway, + golden: "rules_invalid_upstream_response.golden", + }, } { t.Run(fmt.Sprintf("%s=%s", proxyLabel, tc.labelv), func(t *testing.T) { m := newMockUpstream(tc.upstream) defer m.Close() - r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}) + r, err := NewRoutes( + m.url, + proxyLabel, + HTTPFormEnforcer{ParameterName: proxyLabel}, + tc.opts..., + ) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -508,6 +530,7 @@ func TestAlerts(t *testing.T) { for _, tc := range []struct { labelv []string upstream http.Handler + opts []Option expCode int golden string @@ -577,11 +600,32 @@ func TestAlerts(t *testing.T) { expCode: http.StatusOK, golden: "alerts_match_namespaces_ns1_and_ns2.golden", }, + { + labelv: []string{"ns1|ns2"}, + upstream: validAlerts(), + opts: []Option{WithRegexMatch()}, + + expCode: http.StatusOK, + golden: "alerts_match_namespaces_ns1_and_ns2.golden", + }, + { + labelv: []string{"ns1", "ns2"}, + upstream: validAlerts(), + opts: []Option{WithRegexMatch()}, + + expCode: http.StatusBadGateway, + golden: "alerts_invalid_upstream_response.golden", + }, } { - t.Run(fmt.Sprintf("%s=%s", proxyLabel, tc.labelv), func(t *testing.T) { + t.Run(fmt.Sprintf("%s=%#v", proxyLabel, tc.labelv), func(t *testing.T) { m := newMockUpstream(tc.upstream) defer m.Close() - r, err := NewRoutes(m.url, proxyLabel, HTTPFormEnforcer{ParameterName: proxyLabel}) + r, err := NewRoutes( + m.url, + proxyLabel, + HTTPFormEnforcer{ParameterName: proxyLabel}, + tc.opts..., + ) if err != nil { t.Fatalf("unexpected error: %v", err) }