Alerting: Support recording rule struct in provisioning API (#87849)

* Support record struct in provisioning API

* Update api spec

* Use record field

* Restrict API endpoints following toggle

* Fix swagger spec

* Add recording rule validation to store validator
This commit is contained in:
William Wernert 2024-06-06 14:05:02 -04:00 committed by GitHub
parent 808cf75ff8
commit d359591dac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 426 additions and 202 deletions

View File

@ -164,6 +164,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
templates: api.Templates, templates: api.Templates,
muteTimings: api.MuteTimings, muteTimings: api.MuteTimings,
alertRules: api.AlertRules, alertRules: api.AlertRules,
// XXX: Used to flag recording rules, remove when FT is removed
featureManager: api.FeatureManager,
}), m) }), m)
api.RegisterHistoryApiEndpoints(NewStateHistoryApi(&HistorySrv{ api.RegisterHistoryApiEndpoints(NewStateHistoryApi(&HistorySrv{

View File

@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/ngalert/api/hcl" "github.com/grafana/grafana/pkg/services/ngalert/api/hcl"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@ -30,6 +31,9 @@ type ProvisioningSrv struct {
muteTimings MuteTimingService muteTimings MuteTimingService
alertRules AlertRuleService alertRules AlertRuleService
folderSvc folder.Service folderSvc folder.Service
// XXX: Used to flag recording rules, remove when FT is removed
featureManager featuremgmt.FeatureToggles
} }
type ContactPointService interface { type ContactPointService interface {
@ -335,6 +339,15 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *contextmodel.ReqContext, ar de
if err != nil { if err != nil {
return ErrResp(http.StatusBadRequest, err, "") return ErrResp(http.StatusBadRequest, err, "")
} }
if upstreamModel.IsRecordingRule() && !srv.featureManager.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRules) {
return ErrResp(
http.StatusBadRequest,
fmt.Errorf("%w: recording rules cannot be created on this instance", alerting_models.ErrAlertRuleFailedValidation),
"",
)
}
provenance := determineProvenance(c) provenance := determineProvenance(c)
createdAlertRule, err := srv.alertRules.CreateAlertRule(c.Req.Context(), c.SignedInUser, upstreamModel, alerting_models.Provenance(provenance)) createdAlertRule, err := srv.alertRules.CreateAlertRule(c.Req.Context(), c.SignedInUser, upstreamModel, alerting_models.Provenance(provenance))
if errors.Is(err, alerting_models.ErrAlertRuleFailedValidation) { if errors.Is(err, alerting_models.ErrAlertRuleFailedValidation) {
@ -362,6 +375,15 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar def
if err != nil { if err != nil {
ErrResp(http.StatusBadRequest, err, "") ErrResp(http.StatusBadRequest, err, "")
} }
if updated.IsRecordingRule() && !srv.featureManager.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRules) {
return ErrResp(
http.StatusBadRequest,
fmt.Errorf("%w: recording rules cannot be created on this instance", alerting_models.ErrAlertRuleFailedValidation),
"",
)
}
updated.OrgID = c.SignedInUser.GetOrgID() updated.OrgID = c.SignedInUser.GetOrgID()
updated.UID = UID updated.UID = UID
provenance := determineProvenance(c) provenance := determineProvenance(c)

View File

@ -406,6 +406,99 @@ func TestProvisioningApi(t *testing.T) {
}) })
}) })
t.Run("recording rules", func(t *testing.T) {
t.Run("are enabled", func(t *testing.T) {
env := createTestEnv(t, testConfig)
env.features = featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules)
t.Run("POST returns 201", func(t *testing.T) {
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rule := createTestRecordingRule("rule", 1)
response := sut.RoutePostAlertRule(&rc, rule)
require.Equal(t, 201, response.Status())
})
t.Run("PUT returns 200", func(t *testing.T) {
sut := createProvisioningSrvSutFromEnv(t, &env)
uid := util.GenerateShortUID()
rule := createTestAlertRule("rule", 3)
rule.UID = uid
_, err := sut.folderSvc.Create(context.Background(), &folder.CreateFolderCommand{
UID: rule.FolderUID,
Title: "Folder Title",
OrgID: rule.OrgID,
SignedInUser: &user.SignedInUser{OrgID: rule.OrgID},
})
require.NoError(t, err)
insertRuleInOrg(t, sut, rule, 3)
// make rule a recording rule
rule.Record = &definitions.Record{
Metric: "test_metric",
From: "A",
}
rc := createTestRequestCtx()
rc.SignedInUser.OrgID = 3
response := sut.RoutePutAlertRule(&rc, rule, rule.UID)
require.Equal(t, 200, response.Status())
})
})
t.Run("are not enabled", func(t *testing.T) {
t.Run("POST returns 400", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rule := createTestRecordingRule("rule", 1)
response := sut.RoutePostAlertRule(&rc, rule)
require.Equal(t, 400, response.Status())
require.NotEmpty(t, response.Body())
require.Contains(t, string(response.Body()), "recording rules cannot be created on this instance")
})
t.Run("PUT returns 400", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
uid := util.GenerateShortUID()
rule := createTestAlertRule("rule", 3)
rule.UID = uid
_, err := sut.folderSvc.Create(context.Background(), &folder.CreateFolderCommand{
UID: rule.FolderUID,
Title: "Folder Title",
OrgID: rule.OrgID,
SignedInUser: &user.SignedInUser{OrgID: rule.OrgID},
})
require.NoError(t, err)
insertRuleInOrg(t, sut, rule, 3)
// make rule a recording rule
rule.Record = &definitions.Record{
Metric: "test_metric",
From: "A",
}
rc := createTestRequestCtx()
rc.SignedInUser.OrgID = 3
response := sut.RoutePutAlertRule(&rc, rule, rule.UID)
require.Equal(t, 400, response.Status())
require.NotEmpty(t, response.Body())
require.Contains(t, string(response.Body()), "recording rules cannot be created on this instance")
})
})
})
t.Run("alert rule groups", func(t *testing.T) { t.Run("alert rule groups", func(t *testing.T) {
t.Run("are present", func(t *testing.T) { t.Run("are present", func(t *testing.T) {
sut := createProvisioningSrvSut(t) sut := createProvisioningSrvSut(t)
@ -1644,6 +1737,7 @@ type testEnvironment struct {
ac *recordingAccessControlFake ac *recordingAccessControlFake
user *user.SignedInUser user *user.SignedInUser
rulesAuthz *fakes.FakeRuleService rulesAuthz *fakes.FakeRuleService
features featuremgmt.FeatureToggles
} }
func createTestEnv(t *testing.T, testConfig string) testEnvironment { func createTestEnv(t *testing.T, testConfig string) testEnvironment {
@ -1738,6 +1832,8 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
ruleAuthz := &fakes.FakeRuleService{} ruleAuthz := &fakes.FakeRuleService{}
features := featuremgmt.WithFeatures()
return testEnvironment{ return testEnvironment{
secrets: secretsService, secrets: secretsService,
log: log, log: log,
@ -1751,6 +1847,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
ac: ac, ac: ac,
user: user, user: user,
rulesAuthz: ruleAuthz, rulesAuthz: ruleAuthz,
features: features,
} }
} }
@ -1773,6 +1870,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi
muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log), muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log),
alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.folderService, env.quotas, env.xact, 60, 10, 100, env.log, &provisioning.NotificationSettingsValidatorProviderFake{}, env.rulesAuthz), alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.folderService, env.quotas, env.xact, 60, 10, 100, env.log, &provisioning.NotificationSettingsValidatorProviderFake{}, env.rulesAuthz),
folderSvc: env.folderService, folderSvc: env.folderService,
featureManager: env.features,
} }
} }
@ -1973,6 +2071,32 @@ func createTestAlertRule(title string, orgID int64) definitions.ProvisionedAlert
} }
} }
func createTestRecordingRule(title string, orgID int64) definitions.ProvisionedAlertRule {
return definitions.ProvisionedAlertRule{
UID: title,
OrgID: orgID,
Title: title,
Condition: "A",
Data: []definitions.AlertQuery{
{
RefID: "A",
Model: json.RawMessage(testModel),
RelativeTimeRange: definitions.RelativeTimeRange{
From: definitions.Duration(60),
To: definitions.Duration(0),
},
},
},
RuleGroup: "my-cool-group",
FolderUID: "folder-uid",
For: model.Duration(60),
Record: &definitions.Record{
Metric: "test_record",
From: "A",
},
}
}
func insertRule(t *testing.T, srv ProvisioningSrv, rule definitions.ProvisionedAlertRule) { func insertRule(t *testing.T, srv ProvisioningSrv, rule definitions.ProvisionedAlertRule) {
insertRuleInOrg(t, srv, rule, 1) insertRuleInOrg(t, srv, rule, 1)
} }

View File

@ -32,9 +32,7 @@ func AlertRuleFromProvisionedAlertRule(a definitions.ProvisionedAlertRule) (mode
Labels: a.Labels, Labels: a.Labels,
IsPaused: a.IsPaused, IsPaused: a.IsPaused,
NotificationSettings: NotificationSettingsFromAlertRuleNotificationSettings(a.NotificationSettings), NotificationSettings: NotificationSettingsFromAlertRuleNotificationSettings(a.NotificationSettings),
// Recording Rule fields will be implemented in the future. Record: ModelRecordFromApiRecord(a.Record),
// For now, no rules can be recording rules. So, we force these to be empty.
Record: nil,
}, nil }, nil
} }
@ -58,6 +56,7 @@ func ProvisionedAlertRuleFromAlertRule(rule models.AlertRule, provenance models.
Provenance: definitions.Provenance(provenance), // TODO validate enum conversion? Provenance: definitions.Provenance(provenance), // TODO validate enum conversion?
IsPaused: rule.IsPaused, IsPaused: rule.IsPaused,
NotificationSettings: AlertRuleNotificationSettingsFromNotificationSettings(rule.NotificationSettings), NotificationSettings: AlertRuleNotificationSettingsFromNotificationSettings(rule.NotificationSettings),
Record: ApiRecordFromModelRecord(rule.Record),
} }
} }
@ -192,6 +191,7 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE
ExecErrState: definitions.ExecutionErrorState(rule.ExecErrState), ExecErrState: definitions.ExecutionErrorState(rule.ExecErrState),
IsPaused: rule.IsPaused, IsPaused: rule.IsPaused,
NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings), NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings),
Record: AlertRuleRecordExportFromRecord(rule.Record),
} }
if rule.For.Seconds() > 0 { if rule.For.Seconds() > 0 {
result.ForString = util.Pointer(model.Duration(rule.For).String()) result.ForString = util.Pointer(model.Duration(rule.For).String())
@ -456,11 +456,11 @@ func NotificationSettingsFromAlertRuleNotificationSettings(ns *definitions.Alert
} }
} }
func ApiRecordFromModelRecord(r *models.Record) *definitions.Record { func AlertRuleRecordExportFromRecord(r *models.Record) *definitions.AlertRuleRecordExport {
if r == nil { if r == nil {
return nil return nil
} }
return &definitions.Record{ return &definitions.AlertRuleRecordExport{
Metric: r.Metric, Metric: r.Metric,
From: r.From, From: r.From,
} }
@ -475,3 +475,13 @@ func ModelRecordFromApiRecord(r *definitions.Record) *models.Record {
From: r.From, From: r.From,
} }
} }
func ApiRecordFromModelRecord(r *models.Record) *definitions.Record {
if r == nil {
return nil
}
return &definitions.Record{
Metric: r.Metric,
From: r.From,
}
}

View File

@ -218,6 +218,9 @@
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
}, },
"record": {
"$ref": "#/definitions/AlertRuleRecordExport"
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@ -367,6 +370,18 @@
"title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.",
"type": "object" "type": "object"
}, },
"AlertRuleRecordExport": {
"properties": {
"from": {
"type": "string"
},
"metric": {
"type": "string"
}
},
"title": "Record is the provisioned export of models.Record.",
"type": "object"
},
"AlertingFileExport": { "AlertingFileExport": {
"properties": { "properties": {
"apiVersion": { "apiVersion": {
@ -2922,6 +2937,9 @@
"provenance": { "provenance": {
"$ref": "#/definitions/Provenance" "$ref": "#/definitions/Provenance"
}, },
"record": {
"$ref": "#/definitions/Record"
},
"ruleGroup": { "ruleGroup": {
"example": "eval_group_1", "example": "eval_group_1",
"maxLength": 190, "maxLength": 190,
@ -3256,13 +3274,20 @@
"Record": { "Record": {
"properties": { "properties": {
"from": { "from": {
"description": "Which expression node should be used as the input for the recorded metric.",
"example": "A",
"type": "string" "type": "string"
}, },
"metric": { "metric": {
"description": "Name of the recorded metric.",
"example": "grafana_alerts_ratio",
"type": "string" "type": "string"
} }
}, },
"title": "Record defines how data produced by a recording rule is written.", "required": [
"metric",
"from"
],
"type": "object" "type": "object"
}, },
"RelativeTimeRange": { "RelativeTimeRange": {
@ -4435,7 +4460,6 @@
"type": "object" "type": "object"
}, },
"alertGroup": { "alertGroup": {
"description": "AlertGroup alert group",
"properties": { "properties": {
"alerts": { "alerts": {
"description": "alerts", "description": "alerts",
@ -4459,7 +4483,6 @@
"type": "object" "type": "object"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, },
@ -4626,6 +4649,7 @@
"type": "array" "type": "array"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@ -4681,6 +4705,7 @@
"type": "array" "type": "array"
}, },
"integration": { "integration": {
"description": "Integration integration",
"properties": { "properties": {
"lastNotifyAttempt": { "lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@ -4861,7 +4886,6 @@
"type": "object" "type": "object"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"active": { "active": {
"description": "active", "description": "active",

View File

@ -477,6 +477,18 @@ type AlertRuleNotificationSettings struct {
MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"` MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"`
} }
// swagger:model
type Record struct {
// Name of the recorded metric.
// required: true
// example: grafana_alerts_ratio
Metric string `json:"metric" yaml:"metric"`
// Which expression node should be used as the input for the recorded metric.
// required: true
// example: A
From string `json:"from" yaml:"from"`
}
// swagger:model // swagger:model
type PostableGrafanaRule struct { type PostableGrafanaRule struct {
Title string `json:"title" yaml:"title"` Title string `json:"title" yaml:"title"`
@ -578,12 +590,6 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
} }
} }
// Record defines how data produced by a recording rule is written.
type Record struct {
Metric string `json:"metric" yaml:"metric"`
From string `json:"from" yaml:"from"`
}
// swagger:model // swagger:model
type UpdateRuleGroupResponse struct { type UpdateRuleGroupResponse struct {
Message string `json:"message"` Message string `json:"message"`

View File

@ -167,6 +167,8 @@ type ProvisionedAlertRule struct {
IsPaused bool `json:"isPaused"` IsPaused bool `json:"isPaused"`
// example: {"receiver":"email","group_by":["alertname","grafana_folder","cluster"],"group_wait":"30s","group_interval":"1m","repeat_interval":"4d","mute_time_intervals":["Weekends","Holidays"]} // example: {"receiver":"email","group_by":["alertname","grafana_folder","cluster"],"group_wait":"30s","group_interval":"1m","repeat_interval":"4d","mute_time_intervals":["Weekends","Holidays"]}
NotificationSettings *AlertRuleNotificationSettings `json:"notification_settings"` NotificationSettings *AlertRuleNotificationSettings `json:"notification_settings"`
//example: {"metric":"grafana_alerts_ratio", "from":"A"}
Record *Record `json:"record"`
} }
// swagger:route GET /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteGetAlertRuleGroup // swagger:route GET /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteGetAlertRuleGroup
@ -273,6 +275,7 @@ type AlertRuleExport struct {
Labels *map[string]string `json:"labels,omitempty" yaml:"labels,omitempty" hcl:"labels"` Labels *map[string]string `json:"labels,omitempty" yaml:"labels,omitempty" hcl:"labels"`
IsPaused bool `json:"isPaused" yaml:"isPaused" hcl:"is_paused"` IsPaused bool `json:"isPaused" yaml:"isPaused" hcl:"is_paused"`
NotificationSettings *AlertRuleNotificationSettingsExport `json:"notification_settings,omitempty" yaml:"notification_settings,omitempty" hcl:"notification_settings,block"` NotificationSettings *AlertRuleNotificationSettingsExport `json:"notification_settings,omitempty" yaml:"notification_settings,omitempty" hcl:"notification_settings,block"`
Record *AlertRuleRecordExport `json:"record,omitempty" yaml:"record,omitempty" hcl:"record"`
} }
// AlertQueryExport is the provisioned export of models.AlertQuery. // AlertQueryExport is the provisioned export of models.AlertQuery.
@ -301,3 +304,9 @@ type AlertRuleNotificationSettingsExport struct {
RepeatInterval *string `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty" hcl:"repeat_interval,optional"` RepeatInterval *string `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty" hcl:"repeat_interval,optional"`
MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty" hcl:"mute_timings"` // TF -> `mute_timings` MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty" hcl:"mute_timings"` // TF -> `mute_timings`
} }
// Record is the provisioned export of models.Record.
type AlertRuleRecordExport struct {
Metric string `json:"metric" yaml:"metric" hcl:"metric"`
From string `json:"from" yaml:"from" hcl:"from"`
}

View File

@ -218,6 +218,9 @@
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
}, },
"record": {
"$ref": "#/definitions/AlertRuleRecordExport"
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@ -367,6 +370,18 @@
"title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.",
"type": "object" "type": "object"
}, },
"AlertRuleRecordExport": {
"properties": {
"from": {
"type": "string"
},
"metric": {
"type": "string"
}
},
"title": "Record is the provisioned export of models.Record.",
"type": "object"
},
"AlertingFileExport": { "AlertingFileExport": {
"properties": { "properties": {
"apiVersion": { "apiVersion": {
@ -967,7 +982,9 @@
}, },
"type": "object" "type": "object"
}, },
"EvalQueriesResponse": {}, "EvalQueriesResponse": {
"type": "object"
},
"ExplorePanelsState": { "ExplorePanelsState": {
"description": "This is an object constructed with the keys as the values of the enum VisType and the value being a bag of properties" "description": "This is an object constructed with the keys as the values of the enum VisType and the value being a bag of properties"
}, },
@ -2922,6 +2939,9 @@
"provenance": { "provenance": {
"$ref": "#/definitions/Provenance" "$ref": "#/definitions/Provenance"
}, },
"record": {
"$ref": "#/definitions/Record"
},
"ruleGroup": { "ruleGroup": {
"example": "eval_group_1", "example": "eval_group_1",
"maxLength": 190, "maxLength": 190,
@ -3256,13 +3276,20 @@
"Record": { "Record": {
"properties": { "properties": {
"from": { "from": {
"description": "Which expression node should be used as the input for the recorded metric.",
"example": "A",
"type": "string" "type": "string"
}, },
"metric": { "metric": {
"description": "Name of the recorded metric.",
"example": "grafana_alerts_ratio",
"type": "string" "type": "string"
} }
}, },
"title": "Record defines how data produced by a recording rule is written.", "required": [
"metric",
"from"
],
"type": "object" "type": "object"
}, },
"RelativeTimeRange": { "RelativeTimeRange": {
@ -4461,7 +4488,8 @@
}, },
"alertGroups": { "alertGroups": {
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup",
"type": "object"
}, },
"type": "array" "type": "array"
}, },
@ -4564,49 +4592,6 @@
"type": "object" "type": "object"
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
},
"endsAt": {
"description": "ends at",
"format": "date-time",
"type": "string"
},
"fingerprint": {
"description": "fingerprint",
"type": "string"
},
"generatorURL": {
"description": "generator URL\nFormat: uri",
"format": "uri",
"type": "string"
},
"labels": {
"$ref": "#/definitions/labelSet"
},
"receivers": {
"description": "receivers",
"items": {
"$ref": "#/definitions/receiver"
},
"type": "array"
},
"startsAt": {
"description": "starts at",
"format": "date-time",
"type": "string"
},
"status": {
"$ref": "#/definitions/alertStatus"
},
"updatedAt": {
"description": "updated at",
"format": "date-time",
"type": "string"
}
},
"required": [ "required": [
"labels", "labels",
"annotations", "annotations",
@ -4620,49 +4605,13 @@
"type": "object" "type": "object"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert",
"type": "object"
}, },
"type": "array" "type": "array"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
"type": "string"
},
"createdBy": {
"description": "created by",
"type": "string"
},
"endsAt": {
"description": "ends at",
"format": "date-time",
"type": "string"
},
"id": {
"description": "id",
"type": "string"
},
"matchers": {
"$ref": "#/definitions/matchers"
},
"startsAt": {
"description": "starts at",
"format": "date-time",
"type": "string"
},
"status": {
"$ref": "#/definitions/silenceStatus"
},
"updatedAt": {
"description": "updated at",
"format": "date-time",
"type": "string"
}
},
"required": [ "required": [
"comment", "comment",
"createdBy", "createdBy",
@ -4677,11 +4626,13 @@
}, },
"gettableSilences": { "gettableSilences": {
"items": { "items": {
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence",
"type": "object"
}, },
"type": "array" "type": "array"
}, },
"integration": { "integration": {
"description": "Integration integration",
"properties": { "properties": {
"lastNotifyAttempt": { "lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@ -4825,6 +4776,7 @@
"type": "array" "type": "array"
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@ -4862,6 +4814,7 @@
"type": "object" "type": "object"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"active": { "active": {
"description": "active", "description": "active",

View File

@ -3731,6 +3731,9 @@
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
}, },
"record": {
"$ref": "#/definitions/AlertRuleRecordExport"
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@ -3878,6 +3881,18 @@
} }
} }
}, },
"AlertRuleRecordExport": {
"type": "object",
"title": "Record is the provisioned export of models.Record.",
"properties": {
"from": {
"type": "string"
},
"metric": {
"type": "string"
}
}
},
"AlertingFileExport": { "AlertingFileExport": {
"type": "object", "type": "object",
"title": "AlertingFileExport is the full provisioned file export.", "title": "AlertingFileExport is the full provisioned file export.",
@ -4480,7 +4495,7 @@
} }
}, },
"EvalQueriesResponse": { "EvalQueriesResponse": {
"$ref": "#/definitions/EvalQueriesResponse" "type": "object"
}, },
"ExplorePanelsState": { "ExplorePanelsState": {
"description": "This is an object constructed with the keys as the values of the enum VisType and the value being a bag of properties" "description": "This is an object constructed with the keys as the values of the enum VisType and the value being a bag of properties"
@ -6449,6 +6464,9 @@
"provenance": { "provenance": {
"$ref": "#/definitions/Provenance" "$ref": "#/definitions/Provenance"
}, },
"record": {
"$ref": "#/definitions/Record"
},
"ruleGroup": { "ruleGroup": {
"type": "string", "type": "string",
"maxLength": 190, "maxLength": 190,
@ -6770,13 +6788,20 @@
}, },
"Record": { "Record": {
"type": "object", "type": "object",
"title": "Record defines how data produced by a recording rule is written.", "required": [
"metric",
"from"
],
"properties": { "properties": {
"from": { "from": {
"type": "string" "description": "Which expression node should be used as the input for the recorded metric.",
"type": "string",
"example": "A"
}, },
"metric": { "metric": {
"type": "string" "description": "Name of the recorded metric.",
"type": "string",
"example": "grafana_alerts_ratio"
} }
} }
}, },
@ -7972,15 +7997,15 @@
"receiver": { "receiver": {
"$ref": "#/definitions/receiver" "$ref": "#/definitions/receiver"
} }
}, }
"$ref": "#/definitions/alertGroup"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"type": "array", "type": "array",
"items": { "items": {
"type": "object",
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, }
"$ref": "#/definitions/alertGroups"
}, },
"alertStatus": { "alertStatus": {
"description": "AlertStatus alert status", "description": "AlertStatus alert status",
@ -8081,7 +8106,6 @@
} }
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object", "type": "object",
"required": [ "required": [
"labels", "labels",
@ -8092,58 +8116,14 @@
"startsAt", "startsAt",
"status", "status",
"updatedAt" "updatedAt"
], ]
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
},
"endsAt": {
"description": "ends at",
"type": "string",
"format": "date-time"
},
"fingerprint": {
"description": "fingerprint",
"type": "string"
},
"generatorURL": {
"description": "generator URL\nFormat: uri",
"type": "string",
"format": "uri"
},
"labels": {
"$ref": "#/definitions/labelSet"
},
"receivers": {
"description": "receivers",
"type": "array",
"items": {
"$ref": "#/definitions/receiver"
}
},
"startsAt": {
"description": "starts at",
"type": "string",
"format": "date-time"
},
"status": {
"$ref": "#/definitions/alertStatus"
},
"updatedAt": {
"description": "updated at",
"type": "string",
"format": "date-time"
}
},
"$ref": "#/definitions/gettableAlert"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array", "type": "array",
"items": { "items": {
"type": "object",
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, }
"$ref": "#/definitions/gettableAlerts"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence", "description": "GettableSilence gettable silence",
@ -8192,17 +8172,17 @@
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
} }
}, }
"$ref": "#/definitions/gettableSilence"
}, },
"gettableSilences": { "gettableSilences": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object",
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
}, }
"$ref": "#/definitions/gettableSilences"
}, },
"integration": { "integration": {
"description": "Integration integration",
"type": "object", "type": "object",
"required": [ "required": [
"name", "name",
@ -8230,8 +8210,7 @@
"description": "send resolved", "description": "send resolved",
"type": "boolean" "type": "boolean"
} }
}, }
"$ref": "#/definitions/integration"
}, },
"labelSet": { "labelSet": {
"description": "LabelSet label set", "description": "LabelSet label set",
@ -8347,6 +8326,7 @@
} }
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"type": "object", "type": "object",
"required": [ "required": [
"comment", "comment",
@ -8381,8 +8361,7 @@
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
} }
}, }
"$ref": "#/definitions/postableSilence"
}, },
"receiver": { "receiver": {
"type": "object", "type": "object",
@ -8390,25 +8369,7 @@
"active", "active",
"integrations", "integrations",
"name" "name"
], ]
"properties": {
"active": {
"description": "active",
"type": "boolean"
},
"integrations": {
"description": "integrations",
"type": "array",
"items": {
"$ref": "#/definitions/integration"
}
},
"name": {
"description": "name",
"type": "string"
}
},
"$ref": "#/definitions/receiver"
}, },
"silence": { "silence": {
"description": "Silence silence", "description": "Silence silence",

View File

@ -16,6 +16,7 @@ import (
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data"
prommodels "github.com/prometheus/common/model"
alertingModels "github.com/grafana/alerting/models" alertingModels "github.com/grafana/alerting/models"
@ -510,14 +511,14 @@ func (alertRule *AlertRule) ValidateAlertRule(cfg setting.UnifiedAlertingSetting
return fmt.Errorf("%w: cannot have Panel ID without a Dashboard UID", ErrAlertRuleFailedValidation) return fmt.Errorf("%w: cannot have Panel ID without a Dashboard UID", ErrAlertRuleFailedValidation)
} }
if !alertRule.IsRecordingRule() { var err error
if _, err := ErrStateFromString(string(alertRule.ExecErrState)); err != nil { if alertRule.IsRecordingRule() {
return err err = validateRecordingRuleFields(alertRule)
} } else {
err = validateAlertRuleFields(alertRule)
if _, err := NoDataStateFromString(string(alertRule.NoDataState)); err != nil { }
return err if err != nil {
} return err
} }
if alertRule.For < 0 { if alertRule.For < 0 {
@ -543,6 +544,29 @@ func (alertRule *AlertRule) ValidateAlertRule(cfg setting.UnifiedAlertingSetting
return nil return nil
} }
func validateAlertRuleFields(rule *AlertRule) error {
if _, err := ErrStateFromString(string(rule.ExecErrState)); err != nil {
return err
}
if _, err := NoDataStateFromString(string(rule.NoDataState)); err != nil {
return err
}
return nil
}
func validateRecordingRuleFields(rule *AlertRule) error {
metricName := prommodels.LabelValue(rule.Record.Metric)
if !metricName.IsValid() {
return fmt.Errorf("%w: %s", ErrAlertRuleFailedValidation, "metric name for recording rule must be a valid utf8 string")
}
if !prommodels.IsValidMetricName(metricName) {
return fmt.Errorf("%w: %s", ErrAlertRuleFailedValidation, "metric name for recording rule must be a valid Prometheus metric name")
}
return nil
}
func (alertRule *AlertRule) ResourceType() string { func (alertRule *AlertRule) ResourceType() string {
return "alertRule" return "alertRule"
} }

View File

@ -603,6 +603,7 @@ func CopyRule(r *AlertRule, mutators ...AlertRuleMutator) *AlertRule {
NoDataState: r.NoDataState, NoDataState: r.NoDataState,
ExecErrState: r.ExecErrState, ExecErrState: r.ExecErrState,
For: r.For, For: r.For,
Record: r.Record,
} }
if r.DashboardUID != nil { if r.DashboardUID != nil {

View File

@ -77,7 +77,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
t.Run("updating record field should increase version", func(t *testing.T) { t.Run("updating record field should increase version", func(t *testing.T) {
rule := createRule(t, store, recordingRuleGen) rule := createRule(t, store, recordingRuleGen)
newRule := models.CopyRule(rule) newRule := models.CopyRule(rule)
newRule.Record.Metric = "new-metric" newRule.Record.Metric = "new_metric"
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
Existing: rule, Existing: rule,
@ -721,6 +721,26 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
} }
}) })
t.Run("inserted recording rules fail validation if metric name is invalid", func(t *testing.T) {
t.Run("invalid UTF-8", func(t *testing.T) {
invalidMetric := "my_metric\x80"
invalidRule := recordingRulesGen.Generate()
invalidRule.Record.Metric = invalidMetric
_, err := store.InsertAlertRules(context.Background(), []models.AlertRule{invalidRule})
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "metric name for recording rule must be a valid utf8 string")
})
t.Run("invalid metric name", func(t *testing.T) {
invalidMetric := "with-dashes"
invalidRule := recordingRulesGen.Generate()
invalidRule.Record.Metric = invalidMetric
_, err := store.InsertAlertRules(context.Background(), []models.AlertRule{invalidRule})
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "metric name for recording rule must be a valid Prometheus metric name")
})
})
t.Run("fail to insert rules with same ID", func(t *testing.T) { t.Run("fail to insert rules with same ID", func(t *testing.T) {
_, err = store.InsertAlertRules(context.Background(), []models.AlertRule{rules[0]}) _, err = store.InsertAlertRules(context.Background(), []models.AlertRule{rules[0]})
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase) require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)

View File

@ -75,6 +75,7 @@ type AlertRuleV1 struct {
Labels values.StringMapValue `json:"labels" yaml:"labels"` Labels values.StringMapValue `json:"labels" yaml:"labels"`
IsPaused values.BoolValue `json:"isPaused" yaml:"isPaused"` IsPaused values.BoolValue `json:"isPaused" yaml:"isPaused"`
NotificationSettings *NotificationSettingsV1 `json:"notification_settings" yaml:"notification_settings"` NotificationSettings *NotificationSettingsV1 `json:"notification_settings" yaml:"notification_settings"`
Record *RecordV1 `json:"record" yaml:"record"`
} }
func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) { func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) {
@ -139,6 +140,13 @@ func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) {
} }
alertRule.NotificationSettings = append(alertRule.NotificationSettings, ns) alertRule.NotificationSettings = append(alertRule.NotificationSettings, ns)
} }
if rule.Record != nil {
record, err := rule.Record.mapToModel()
if err != nil {
return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err)
}
alertRule.Record = &record
}
return alertRule, nil return alertRule, nil
} }
@ -246,3 +254,15 @@ func (nsV1 *NotificationSettingsV1) mapToModel() (models.NotificationSettings, e
MuteTimeIntervals: mute, MuteTimeIntervals: mute,
}, nil }, nil
} }
type RecordV1 struct {
Metric values.StringValue `json:"metric" yaml:"metric"`
From values.StringValue `json:"from" yaml:"from"`
}
func (record *RecordV1) mapToModel() (models.Record, error) {
return models.Record{
Metric: record.Metric.Value(),
From: record.From.Value(),
}, nil
}

View File

@ -12191,6 +12191,9 @@
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
}, },
"record": {
"$ref": "#/definitions/AlertRuleRecordExport"
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@ -12338,6 +12341,18 @@
} }
} }
}, },
"AlertRuleRecordExport": {
"type": "object",
"title": "Record is the provisioned export of models.Record.",
"properties": {
"from": {
"type": "string"
},
"metric": {
"type": "string"
}
}
},
"AlertingFileExport": { "AlertingFileExport": {
"type": "object", "type": "object",
"title": "AlertingFileExport is the full provisioned file export.", "title": "AlertingFileExport is the full provisioned file export.",
@ -17944,6 +17959,9 @@
"provenance": { "provenance": {
"$ref": "#/definitions/Provenance" "$ref": "#/definitions/Provenance"
}, },
"record": {
"$ref": "#/definitions/Record"
},
"ruleGroup": { "ruleGroup": {
"type": "string", "type": "string",
"maxLength": 190, "maxLength": 190,
@ -18511,13 +18529,20 @@
}, },
"Record": { "Record": {
"type": "object", "type": "object",
"title": "Record defines how data produced by a recording rule is written.", "required": [
"metric",
"from"
],
"properties": { "properties": {
"from": { "from": {
"type": "string" "description": "Which expression node should be used as the input for the recorded metric.",
"type": "string",
"example": "A"
}, },
"metric": { "metric": {
"type": "string" "description": "Name of the recorded metric.",
"type": "string",
"example": "grafana_alerts_ratio"
} }
} }
}, },
@ -21472,7 +21497,6 @@
} }
}, },
"alertGroup": { "alertGroup": {
"description": "AlertGroup alert group",
"type": "object", "type": "object",
"required": [ "required": [
"alerts", "alerts",
@ -21496,7 +21520,6 @@
} }
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
@ -21691,6 +21714,7 @@
} }
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object", "type": "object",
"required": [ "required": [
"comment", "comment",
@ -21746,6 +21770,7 @@
} }
}, },
"integration": { "integration": {
"description": "Integration integration",
"type": "object", "type": "object",
"required": [ "required": [
"name", "name",
@ -21954,7 +21979,6 @@
} }
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"type": "object", "type": "object",
"required": [ "required": [
"active", "active",

View File

@ -2585,6 +2585,9 @@
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
}, },
"record": {
"$ref": "#/components/schemas/AlertRuleRecordExport"
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@ -2734,6 +2737,18 @@
"title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.",
"type": "object" "type": "object"
}, },
"AlertRuleRecordExport": {
"properties": {
"from": {
"type": "string"
},
"metric": {
"type": "string"
}
},
"title": "Record is the provisioned export of models.Record.",
"type": "object"
},
"AlertingFileExport": { "AlertingFileExport": {
"properties": { "properties": {
"apiVersion": { "apiVersion": {
@ -8328,6 +8343,9 @@
"provenance": { "provenance": {
"$ref": "#/components/schemas/Provenance" "$ref": "#/components/schemas/Provenance"
}, },
"record": {
"$ref": "#/components/schemas/Record"
},
"ruleGroup": { "ruleGroup": {
"example": "eval_group_1", "example": "eval_group_1",
"maxLength": 190, "maxLength": 190,
@ -8908,13 +8926,20 @@
"Record": { "Record": {
"properties": { "properties": {
"from": { "from": {
"description": "Which expression node should be used as the input for the recorded metric.",
"example": "A",
"type": "string" "type": "string"
}, },
"metric": { "metric": {
"description": "Name of the recorded metric.",
"example": "grafana_alerts_ratio",
"type": "string" "type": "string"
} }
}, },
"title": "Record defines how data produced by a recording rule is written.", "required": [
"metric",
"from"
],
"type": "object" "type": "object"
}, },
"RecordingRuleJSON": { "RecordingRuleJSON": {
@ -11867,7 +11892,6 @@
"type": "object" "type": "object"
}, },
"alertGroup": { "alertGroup": {
"description": "AlertGroup alert group",
"properties": { "properties": {
"alerts": { "alerts": {
"description": "alerts", "description": "alerts",
@ -11891,7 +11915,6 @@
"type": "object" "type": "object"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"items": { "items": {
"$ref": "#/components/schemas/alertGroup" "$ref": "#/components/schemas/alertGroup"
}, },
@ -12086,6 +12109,7 @@
"type": "array" "type": "array"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@ -12141,6 +12165,7 @@
"type": "array" "type": "array"
}, },
"integration": { "integration": {
"description": "Integration integration",
"properties": { "properties": {
"lastNotifyAttempt": { "lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@ -12349,7 +12374,6 @@
"type": "object" "type": "object"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"active": { "active": {
"description": "active", "description": "active",