Skip to content

Commit

Permalink
fix: support regex for non-query endpoints
Browse files Browse the repository at this point in the history
PR prometheus-community#171 implemented support for regex label values but only for the
query endpoints. This change adds support for all other Prometheus API
endpoints.

Signed-off-by: Simon Pasquier <[email protected]>
  • Loading branch information
simonpasquier committed Jun 12, 2024
1 parent 435301e commit 371b506
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 30 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,11 @@ NOTE: When the `/api/v1/labels` and `/api/v1/label/<name>/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
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
52 changes: 48 additions & 4 deletions injectproxy/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -577,21 +580,62 @@ 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,
// /api/v1/label/<name>/values, /api/v1/labels and /federate which support
// 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
}

Expand Down
78 changes: 61 additions & 17 deletions injectproxy/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -264,6 +265,7 @@ func TestMatch(t *testing.T) {
for _, tc := range []struct {
labelv []string
matches []string
opts []Option

expCode int
expMatch []string
Expand All @@ -277,15 +279,15 @@ func TestMatch(t *testing.T) {
// No "match" parameter.
labelv: []string{"default"},
expCode: http.StatusOK,
expMatch: []string{`{namespace=~"default"}`},
expMatch: []string{`{namespace="default"}`},
expBody: okResponse,
},
{
// Single "match" parameters.
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,
},
{
Expand All @@ -309,15 +311,15 @@ 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,
},
{
// Many "match" parameters.
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,
},
{
Expand All @@ -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",
Expand All @@ -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)
}
Expand Down Expand Up @@ -382,6 +425,7 @@ func TestMatch(t *testing.T) {
t.Logf("%s", string(body))
t.FailNow()
}

if resp.StatusCode != http.StatusOK {
return
}
Expand Down Expand Up @@ -411,15 +455,15 @@ func TestMatchWithPost(t *testing.T) {
// No "match" parameter.
labelv: []string{"default"},
expCode: http.StatusOK,
expMatch: []string{`{namespace=~"default"}`},
expMatch: []string{`{namespace="default"}`},
expBody: okResponse,
},
{
// Single "match" parameters.
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,
},
{
Expand All @@ -443,15 +487,15 @@ 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,
},
{
// Many "match" parameters.
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,
},
{
Expand Down Expand Up @@ -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,
},
Expand All @@ -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,
},
{
Expand All @@ -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,
},
{
Expand Down Expand Up @@ -679,15 +723,15 @@ 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,
},
{
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,
},
Expand All @@ -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,
},
{
Expand All @@ -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,
},
{
Expand Down
16 changes: 13 additions & 3 deletions injectproxy/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"time"

"github.com/prometheus/prometheus/model/labels"
"golang.org/x/exp/slices"
)

type apiResponse struct {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand Down
Loading

0 comments on commit 371b506

Please sign in to comment.