cmd/rule-evaluator/internal/rules.go (157 lines of code) (raw):
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package internal
import (
"fmt"
"net/http"
"slices"
"strings"
"github.com/go-kit/log/level"
"github.com/prometheus/prometheus/rules"
apiv1 "github.com/prometheus/prometheus/web/api/v1"
)
const (
ruleKindAlerting string = "alerting"
ruleKindRecording string = "recording"
ruleTypeFilterAlert string = "alert"
ruleTypeFilterRecord string = "record"
ruleFilterQueryParamName = "rule_name[]"
fileFilterQueryParamName = "file[]"
groupFilterQueryParamName = "rule_group[]"
matchFilterQueryParamName = "match[]"
typeFilterQueryParamName = "type"
excludeAlertsQueryParam = "exclude_alerts"
rulesEndpoint = "/api/v1/rules"
)
// sanitizeFilterList removes empty strings from a list of filters and trims spaces from each filter.
func sanitizeFilterList(filters []string) []string {
filterSet := []string{}
for _, filter := range filters {
filter = strings.Trim(filter, " ")
if filter != "" {
filterSet = append(filterSet, filter)
}
}
return filterSet
}
type rulesEndpointResponse struct {
Groups []*apiv1.RuleGroup `json:"groups"`
}
func (api *API) HandleRulesEndpoint(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
api.writeError(w, errorBadData, "failed to parse request parameters", http.StatusBadRequest, rulesEndpoint)
return
}
ruleFilters := sanitizeFilterList(r.Form[ruleFilterQueryParamName])
fileFilters := sanitizeFilterList(r.Form[fileFilterQueryParamName])
groupFilters := sanitizeFilterList(r.Form[groupFilterQueryParamName])
matchFilters := sanitizeFilterList(r.Form[matchFilterQueryParamName])
if len(matchFilters) > 0 {
// Todo: Once the github.com/prometheus/prometheus dependency is updated to v2.54.0 or higher, we can add
// support for labels-matcher filter, using the match[] request parameter.
// Ref: https://github.com/prometheus/prometheus/releases/tag/v2.54.0
// Ref: https://github.com/prometheus/prometheus/pull/10194
api.writeError(w, errorBadData, "match[] parameter is not supported yet", http.StatusBadRequest, rulesEndpoint)
return
}
ruleTypeFilter := strings.Trim(strings.ToLower(r.URL.Query().Get(typeFilterQueryParamName)), " ")
if !slices.Contains([]string{"", ruleTypeFilterAlert, ruleTypeFilterRecord}, ruleTypeFilter) {
api.writeError(w, errorBadData, "invalid type parameter", http.StatusBadRequest, rulesEndpoint)
return
}
shouldReturnAlertRules := ruleTypeFilter == "" || ruleTypeFilter == ruleTypeFilterAlert
shouldReturnRecordingRules := ruleTypeFilter == "" || ruleTypeFilter == ruleTypeFilterRecord
excludeAlertsParam := strings.Trim(strings.ToLower(r.URL.Query().Get(excludeAlertsQueryParam)), " ")
if !slices.Contains([]string{"", "true", "false"}, excludeAlertsParam) {
api.writeError(w, errorBadData, "invalid exclude_alerts parameter", http.StatusBadRequest, rulesEndpoint)
return
}
shouldExcludeAlertsFromAlertRules := excludeAlertsParam == "true"
apiGroups := api.groupsToAPIGroups(api.rulesManager.RuleGroups(), ruleFilters, fileFilters, groupFilters, shouldReturnAlertRules, shouldReturnRecordingRules, shouldExcludeAlertsFromAlertRules)
responseObject := rulesEndpointResponse{Groups: apiGroups}
api.writeSuccessResponse(w, http.StatusOK, rulesEndpoint, responseObject)
}
// groupsToAPIGroups converts a slice of rules.Group to a slice of apiv1.RuleGroup.
func (api *API) groupsToAPIGroups(groups []*rules.Group, ruleFilters, fileFilters, groupFilters []string, shouldReturnAlertRules, shouldReturnRecordingRules, shouldExcludeAlertsFromAlertRules bool) []*apiv1.RuleGroup {
apiGroups := []*apiv1.RuleGroup{} // don't pre-allocate, we don't know how many rule groups we will return
for _, group := range groups {
// If a rule_group parameter was specified, skip the rule group if it doesn't match any of the specified values.
if len(groupFilters) > 0 && !slices.Contains(groupFilters, group.Name()) {
continue
}
// If a file parameter was specified, skip the rule group if it doesn't match any of the specified values.
if len(fileFilters) > 0 && !slices.Contains(fileFilters, group.File()) {
continue
}
apiGroup := api.groupToAPIGroup(group, ruleFilters, shouldReturnAlertRules, shouldReturnRecordingRules, shouldExcludeAlertsFromAlertRules)
// If we filtered out all rules from the group, skip the group.
if len(apiGroup.Rules) == 0 {
continue
}
apiGroups = append(apiGroups, apiGroup)
}
return apiGroups
}
// groupToAPIGroup converts a rules.Group to an apiv1.RuleGroup.
func (api *API) groupToAPIGroup(group *rules.Group, ruleFilters []string, shouldReturnAlertRules, shouldReturnRecordingRules, shouldExcludeAlertsFromAlertRules bool) *apiv1.RuleGroup {
apiGroupRules := []apiv1.Rule{}
for _, groupRules := range group.Rules() {
// If a rule_name parameter was specified, skip the rule if it doesn't match any of the specified values.
if len(ruleFilters) > 0 && !slices.Contains(ruleFilters, groupRules.Name()) {
continue
}
switch rule := groupRules.(type) {
case *rules.AlertingRule:
if !shouldReturnAlertRules {
continue
}
apiGroupRules = append(apiGroupRules, alertingRuleToAPIRule(rule, shouldExcludeAlertsFromAlertRules))
case *rules.RecordingRule:
if !shouldReturnRecordingRules {
continue
}
apiGroupRules = append(apiGroupRules, recordingRuleToAPIRule(rule))
default:
err := fmt.Errorf("alert rule %s is of unknown type %T", rule.Name(), rule)
_ = level.Warn(api.logger).Log("msg", "failed to convert rule to API rule", "err", err)
continue // ignore faulty rules - this should not break the endpoint.
}
}
return &apiv1.RuleGroup{
Name: group.Name(),
File: group.File(),
Interval: group.Interval().Seconds(),
Limit: group.Limit(),
Rules: apiGroupRules,
EvaluationTime: group.GetEvaluationTime().Seconds(),
LastEvaluation: group.GetLastEvaluation(),
}
}
// recordingRuleToAPIRule converts a rules.RecordingRule to an apiv1.RecordingRule.
func recordingRuleToAPIRule(rule *rules.RecordingRule) *apiv1.RecordingRule {
lastError := ""
if rule.LastError() != nil {
lastError = rule.LastError().Error()
}
return &apiv1.RecordingRule{
Name: rule.Name(),
Query: rule.Query().String(),
Labels: rule.Labels(),
Health: rule.Health(),
LastError: lastError,
EvaluationTime: rule.GetEvaluationDuration().Seconds(),
LastEvaluation: rule.GetEvaluationTimestamp(),
Type: ruleKindRecording,
}
}
// alertingRuleToAPIRule converts a rules.AlertingRule to an apiv1.AlertingRule.
func alertingRuleToAPIRule(rule *rules.AlertingRule, shouldExcludeAlertsFromAlertRules bool) *apiv1.AlertingRule {
lastError := ""
if rule.LastError() != nil {
lastError = rule.LastError().Error()
}
alerts := []*apiv1.Alert{}
if !shouldExcludeAlertsFromAlertRules {
alerts = alertsToAPIAlerts(rule.ActiveAlerts())
}
return &apiv1.AlertingRule{
State: rule.State().String(),
Name: rule.Name(),
Query: rule.Query().String(),
Duration: rule.HoldDuration().Seconds(),
KeepFiringFor: rule.KeepFiringFor().Seconds(),
Labels: rule.Labels(),
Annotations: rule.Annotations(),
Alerts: alerts,
Health: rule.Health(),
LastError: lastError,
EvaluationTime: rule.GetEvaluationDuration().Seconds(),
LastEvaluation: rule.GetEvaluationTimestamp(),
Type: ruleKindAlerting,
}
}