Alerting: Endpoints for provisioning mute timings (#49635)

* Add validator for mute timing and make it provisionable

* Add tests to ensure prometheus validators are running and errors are propagated

* Internal API for manipulating mute timings

* Define and generate API layer

* Wire up generated code

* Implement API handlers

* Tests for golang layer

* Fix reference bug

* Fix linter and auth tests

* Resolve semantic errors and regenerate

* Remove pointless comment

* Extract out provisioning path param keys, simplify

* Expected number of paths
This commit is contained in:
Alexander Weaver 2022-05-26 14:24:34 -05:00 committed by GitHub
parent 097583e952
commit 909ebcf979
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1003 additions and 132 deletions

View File

@ -16,6 +16,9 @@ import (
"github.com/grafana/grafana/pkg/web"
)
const namePathParam = ":name"
const idPathParam = ":ID"
type ProvisioningSrv struct {
log log.Logger
policies NotificationPolicyService
@ -43,7 +46,10 @@ type NotificationPolicyService interface {
}
type MuteTimingService interface {
GetMuteTimings(ctx context.Context, orgID int64) ([]apimodels.MuteTiming, error)
GetMuteTimings(ctx context.Context, orgID int64) ([]apimodels.MuteTimeInterval, error)
CreateMuteTiming(ctx context.Context, mt apimodels.MuteTimeInterval, orgID int64) (*apimodels.MuteTimeInterval, error)
UpdateMuteTiming(ctx context.Context, mt apimodels.MuteTimeInterval, orgID int64) (*apimodels.MuteTimeInterval, error)
DeleteMuteTiming(ctx context.Context, name string, orgID int64) error
}
func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response {
@ -91,7 +97,7 @@ func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp apimo
}
func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
id := web.Params(c.Req)[":ID"]
id := pathParam(c, idPathParam)
cp.UID = id
err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI)
if err != nil {
@ -101,7 +107,7 @@ func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp apimod
}
func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext) response.Response {
cpID := web.Params(c.Req)[":ID"]
cpID := pathParam(c, idPathParam)
err := srv.contactPointService.DeleteContactPoint(c.Req.Context(), c.OrgId, cpID)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
@ -122,19 +128,19 @@ func (srv *ProvisioningSrv) RouteGetTemplates(c *models.ReqContext) response.Res
}
func (srv *ProvisioningSrv) RouteGetTemplate(c *models.ReqContext) response.Response {
id := web.Params(c.Req)[":name"]
name := pathParam(c, namePathParam)
templates, err := srv.templates.GetTemplates(c.Req.Context(), c.OrgId)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
if tmpl, ok := templates[id]; ok {
return response.JSON(http.StatusOK, apimodels.MessageTemplate{Name: id, Template: tmpl})
if tmpl, ok := templates[name]; ok {
return response.JSON(http.StatusOK, apimodels.MessageTemplate{Name: name, Template: tmpl})
}
return response.Empty(http.StatusNotFound)
}
func (srv *ProvisioningSrv) RoutePutTemplate(c *models.ReqContext, body apimodels.MessageTemplateContent) response.Response {
name := web.Params(c.Req)[":name"]
name := pathParam(c, namePathParam)
tmpl := apimodels.MessageTemplate{
Name: name,
Template: body.Template,
@ -151,7 +157,7 @@ func (srv *ProvisioningSrv) RoutePutTemplate(c *models.ReqContext, body apimodel
}
func (srv *ProvisioningSrv) RouteDeleteTemplate(c *models.ReqContext) response.Response {
name := web.Params(c.Req)[":name"]
name := pathParam(c, namePathParam)
err := srv.templates.DeleteTemplate(c.Req.Context(), c.OrgId, name)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
@ -160,7 +166,7 @@ func (srv *ProvisioningSrv) RouteDeleteTemplate(c *models.ReqContext) response.R
}
func (srv *ProvisioningSrv) RouteGetMuteTiming(c *models.ReqContext) response.Response {
name := web.Params(c.Req)[":name"]
name := pathParam(c, namePathParam)
timings, err := srv.muteTimings.GetMuteTimings(c.Req.Context(), c.OrgId)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
@ -180,3 +186,43 @@ func (srv *ProvisioningSrv) RouteGetMuteTimings(c *models.ReqContext) response.R
}
return response.JSON(http.StatusOK, timings)
}
func (srv *ProvisioningSrv) RoutePostMuteTiming(c *models.ReqContext, mt apimodels.MuteTimeInterval) response.Response {
created, err := srv.muteTimings.CreateMuteTiming(c.Req.Context(), mt, c.OrgId)
if err != nil {
if errors.Is(err, provisioning.ErrValidation) {
return ErrResp(http.StatusBadRequest, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusCreated, created)
}
func (srv *ProvisioningSrv) RoutePutMuteTiming(c *models.ReqContext, mt apimodels.MuteTimeInterval) response.Response {
name := pathParam(c, namePathParam)
mt.Name = name
updated, err := srv.muteTimings.UpdateMuteTiming(c.Req.Context(), mt, c.OrgId)
if err != nil {
if errors.Is(err, provisioning.ErrValidation) {
return ErrResp(http.StatusBadRequest, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
}
if updated == nil {
return response.Empty(http.StatusNotFound)
}
return response.JSON(http.StatusAccepted, updated)
}
func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *models.ReqContext) response.Response {
name := pathParam(c, namePathParam)
err := srv.muteTimings.DeleteMuteTiming(c.Req.Context(), name, c.OrgId)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusNoContent, nil)
}
func pathParam(c *models.ReqContext, param string) string {
return web.Params(c.Req)[param]
}

View File

@ -192,7 +192,10 @@ func (api *API) authorize(method, path string) web.Handler {
http.MethodPut + "/api/provisioning/contact-points/{ID}",
http.MethodDelete + "/api/provisioning/contact-points/{ID}",
http.MethodPut + "/api/provisioning/templates/{name}",
http.MethodDelete + "/api/provisioning/templates/{name}":
http.MethodDelete + "/api/provisioning/templates/{name}",
http.MethodPost + "/api/provisioning/mute-timings",
http.MethodPut + "/api/provisioning/mute-timings/{name}",
http.MethodDelete + "/api/provisioning/mute-timings/{name}":
return middleware.ReqEditorRole
}

View File

@ -66,3 +66,15 @@ func (f *ForkedProvisioningApi) forkRouteGetMuteTiming(ctx *models.ReqContext) r
func (f *ForkedProvisioningApi) forkRouteGetMuteTimings(ctx *models.ReqContext) response.Response {
return f.svc.RouteGetMuteTimings(ctx)
}
func (f *ForkedProvisioningApi) forkRoutePostMuteTiming(ctx *models.ReqContext, mt apimodels.MuteTimeInterval) response.Response {
return f.svc.RoutePostMuteTiming(ctx, mt)
}
func (f *ForkedProvisioningApi) forkRoutePutMuteTiming(ctx *models.ReqContext, mt apimodels.MuteTimeInterval) response.Response {
return f.svc.RoutePutMuteTiming(ctx, mt)
}
func (f *ForkedProvisioningApi) forkRouteDeleteMuteTiming(ctx *models.ReqContext) response.Response {
return f.svc.RouteDeleteMuteTiming(ctx)
}

View File

@ -21,6 +21,7 @@ import (
type ProvisioningApiForkingService interface {
RouteDeleteContactpoints(*models.ReqContext) response.Response
RouteDeleteMuteTiming(*models.ReqContext) response.Response
RouteDeleteTemplate(*models.ReqContext) response.Response
RouteGetContactpoints(*models.ReqContext) response.Response
RouteGetMuteTiming(*models.ReqContext) response.Response
@ -29,7 +30,9 @@ type ProvisioningApiForkingService interface {
RouteGetTemplate(*models.ReqContext) response.Response
RouteGetTemplates(*models.ReqContext) response.Response
RoutePostContactpoints(*models.ReqContext) response.Response
RoutePostMuteTiming(*models.ReqContext) response.Response
RoutePutContactpoint(*models.ReqContext) response.Response
RoutePutMuteTiming(*models.ReqContext) response.Response
RoutePutPolicyTree(*models.ReqContext) response.Response
RoutePutTemplate(*models.ReqContext) response.Response
}
@ -38,6 +41,10 @@ func (f *ForkedProvisioningApi) RouteDeleteContactpoints(ctx *models.ReqContext)
return f.forkRouteDeleteContactpoints(ctx)
}
func (f *ForkedProvisioningApi) RouteDeleteMuteTiming(ctx *models.ReqContext) response.Response {
return f.forkRouteDeleteMuteTiming(ctx)
}
func (f *ForkedProvisioningApi) RouteDeleteTemplate(ctx *models.ReqContext) response.Response {
return f.forkRouteDeleteTemplate(ctx)
}
@ -74,6 +81,14 @@ func (f *ForkedProvisioningApi) RoutePostContactpoints(ctx *models.ReqContext) r
return f.forkRoutePostContactpoints(ctx, conf)
}
func (f *ForkedProvisioningApi) RoutePostMuteTiming(ctx *models.ReqContext) response.Response {
conf := apimodels.MuteTimeInterval{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
return f.forkRoutePostMuteTiming(ctx, conf)
}
func (f *ForkedProvisioningApi) RoutePutContactpoint(ctx *models.ReqContext) response.Response {
conf := apimodels.EmbeddedContactPoint{}
if err := web.Bind(ctx.Req, &conf); err != nil {
@ -82,6 +97,14 @@ func (f *ForkedProvisioningApi) RoutePutContactpoint(ctx *models.ReqContext) res
return f.forkRoutePutContactpoint(ctx, conf)
}
func (f *ForkedProvisioningApi) RoutePutMuteTiming(ctx *models.ReqContext) response.Response {
conf := apimodels.MuteTimeInterval{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
return f.forkRoutePutMuteTiming(ctx, conf)
}
func (f *ForkedProvisioningApi) RoutePutPolicyTree(ctx *models.ReqContext) response.Response {
conf := apimodels.Route{}
if err := web.Bind(ctx.Req, &conf); err != nil {
@ -110,6 +133,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
group.Delete(
toMacaronPath("/api/provisioning/mute-timings/{name}"),
api.authorize(http.MethodDelete, "/api/provisioning/mute-timings/{name}"),
metrics.Instrument(
http.MethodDelete,
"/api/provisioning/mute-timings/{name}",
srv.RouteDeleteMuteTiming,
m,
),
)
group.Delete(
toMacaronPath("/api/provisioning/templates/{name}"),
api.authorize(http.MethodDelete, "/api/provisioning/templates/{name}"),
@ -190,6 +223,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
group.Post(
toMacaronPath("/api/provisioning/mute-timings"),
api.authorize(http.MethodPost, "/api/provisioning/mute-timings"),
metrics.Instrument(
http.MethodPost,
"/api/provisioning/mute-timings",
srv.RoutePostMuteTiming,
m,
),
)
group.Put(
toMacaronPath("/api/provisioning/contact-points/{ID}"),
api.authorize(http.MethodPut, "/api/provisioning/contact-points/{ID}"),
@ -200,6 +243,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
group.Put(
toMacaronPath("/api/provisioning/mute-timings/{name}"),
api.authorize(http.MethodPut, "/api/provisioning/mute-timings/{name}"),
metrics.Instrument(
http.MethodPut,
"/api/provisioning/mute-timings/{name}",
srv.RoutePutMuteTiming,
m,
),
)
group.Put(
toMacaronPath("/api/provisioning/policies"),
api.authorize(http.MethodPut, "/api/provisioning/policies"),

View File

@ -1439,26 +1439,9 @@
"type": "object",
"x-go-package": "github.com/prometheus/alertmanager/config"
},
"MuteTiming": {
"properties": {
"name": {
"type": "string",
"x-go-name": "Name"
},
"time_intervals": {
"items": {
"$ref": "#/definitions/TimeInterval"
},
"type": "array",
"x-go-name": "TimeIntervals"
}
},
"type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
"MuteTimings": {
"items": {
"$ref": "#/definitions/MuteTiming"
"$ref": "#/definitions/MuteTimeInterval"
},
"type": "array",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@ -3392,7 +3375,6 @@
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -3444,7 +3426,9 @@
"status",
"updatedAt"
],
"type": "object"
"type": "object",
"x-go-name": "GettableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"gettableSilences": {
"items": {
@ -3581,6 +3565,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@ -3620,11 +3605,10 @@
"matchers",
"startsAt"
],
"type": "object",
"x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"name": {
"description": "name",
@ -3635,9 +3619,7 @@
"required": [
"name"
],
"type": "object",
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "object"
},
"silence": {
"description": "Silence silence",

View File

@ -850,6 +850,20 @@ func checkTimeInterval(r *Route, timeIntervals map[string]struct{}) error {
return nil
}
// swagger:model
type MuteTimeInterval struct {
config.MuteTimeInterval
Provenance models.Provenance `json:"provenance,omitempty"`
}
func (mt *MuteTimeInterval) ResourceType() string {
return "muteTimeInterval"
}
func (mt *MuteTimeInterval) ResourceID() string {
return mt.MuteTimeInterval.Name
}
type PostableApiAlertingConfig struct {
Config `yaml:",inline"`

View File

@ -2,9 +2,13 @@ package definitions
import (
"fmt"
"html/template"
"regexp"
"strings"
"time"
"github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
)
// Validate normalizes a possibly nested Route r, and returns errors if r is invalid.
@ -52,6 +56,37 @@ func (r *Route) validateChild() error {
return nil
}
func (t *MessageTemplate) Validate() error {
if t.Name == "" {
return fmt.Errorf("template must have a name")
}
if t.Template == "" {
return fmt.Errorf("template must have content")
}
_, err := template.New("").Parse(t.Template)
if err != nil {
return fmt.Errorf("invalid template: %w", err)
}
content := strings.TrimSpace(t.Template)
found, err := regexp.MatchString(`\{\{\s*define`, content)
if err != nil {
return fmt.Errorf("failed to match regex: %w", err)
}
if !found {
lines := strings.Split(content, "\n")
for i, s := range lines {
lines[i] = " " + s
}
content = strings.Join(lines, "\n")
content = fmt.Sprintf("{{ define \"%s\" }}\n%s\n{{ end }}", t.Name, content)
}
t.Template = content
return nil
}
// Validate normalizes a Route r, and returns errors if r is an invalid root route. Root routes must satisfy a few additional conditions.
func (r *Route) Validate() error {
if len(r.Receiver) == 0 {
@ -65,3 +100,14 @@ func (r *Route) Validate() error {
}
return r.validateChild()
}
func (mt *MuteTimeInterval) Validate() error {
s, err := yaml.Marshal(mt.MuteTimeInterval)
if err != nil {
return err
}
if err = yaml.Unmarshal(s, &(mt.MuteTimeInterval)); err != nil {
return err
}
return nil
}

View File

@ -3,6 +3,8 @@ package definitions
import (
"testing"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/timeinterval"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
@ -255,3 +257,111 @@ func TestValidateRoutes(t *testing.T) {
}
})
}
func TestValidateMuteTimeInterval(t *testing.T) {
type testCase struct {
desc string
mti MuteTimeInterval
expMsg string
}
t.Run("valid interval", func(t *testing.T) {
cases := []testCase{
{
desc: "nil intervals",
mti: MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "interval",
},
},
},
{
desc: "empty intervals",
mti: MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "interval",
TimeIntervals: []timeinterval.TimeInterval{},
},
},
},
{
desc: "blank interval",
mti: MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "interval",
TimeIntervals: []timeinterval.TimeInterval{
{},
},
},
},
},
{
desc: "simple",
mti: MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "interval",
TimeIntervals: []timeinterval.TimeInterval{
{
Weekdays: []timeinterval.WeekdayRange{
{
InclusiveRange: timeinterval.InclusiveRange{
Begin: 1,
End: 2,
},
},
},
},
},
},
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := c.mti.Validate()
require.NoError(t, err)
})
}
})
t.Run("invalid interval", func(t *testing.T) {
cases := []testCase{
{
desc: "empty",
mti: MuteTimeInterval{},
expMsg: "missing name",
},
{
desc: "empty",
mti: MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "interval",
TimeIntervals: []timeinterval.TimeInterval{
{
Weekdays: []timeinterval.WeekdayRange{
{
InclusiveRange: timeinterval.InclusiveRange{
Begin: -1,
End: 7,
},
},
},
},
},
},
},
expMsg: "unable to convert -1 into weekday",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := c.mti.Validate()
require.ErrorContains(t, err, c.expMsg)
})
}
})
}

View File

@ -1,7 +1,5 @@
package definitions
import prometheus "github.com/prometheus/alertmanager/config"
// swagger:route GET /api/provisioning/mute-timings provisioning RouteGetMuteTimings
//
// Get all the mute timings.
@ -15,20 +13,52 @@ import prometheus "github.com/prometheus/alertmanager/config"
// Get a mute timing.
//
// Responses:
// 200: MuteTiming
// 200: MuteTimeInterval
// 400: ValidationError
// swagger:model
type MuteTiming struct {
prometheus.MuteTimeInterval
}
// swagger:route POST /api/provisioning/mute-timings provisioning RoutePostMuteTiming
//
// Create a new mute timing.
//
// Consumes:
// - application/json
//
// Responses:
// 201: MuteTimeInterval
// 400: ValidationError
// swagger:route PUT /api/provisioning/mute-timings/{name} provisioning RoutePutMuteTiming
//
// Replace an existing mute timing.
//
// Consumes:
// - application/json
//
// Responses:
// 200: MuteTimeInterval
// 400: ValidationError
// swagger:route DELETE /api/provisioning/mute-timings/{name} provisioning RouteDeleteMuteTiming
//
// Delete a mute timing.
//
// Responses:
// 204: Ack
// swagger:route
// swagger:model
type MuteTimings []MuteTiming
type MuteTimings []MuteTimeInterval
// swagger:parameters RouteGetTemplate RouteGetMuteTiming
// swagger:parameters RouteGetTemplate RouteGetMuteTiming RoutePutMuteTiming RouteDeleteMuteTiming
type RouteGetMuteTimingParam struct {
// Template Name
// in:path
Name string `json:"name"`
}
// swagger:parameters RoutePostMuteTiming RoutePutMuteTiming
type MuteTimingPayload struct {
// in:body
Body MuteTimeInterval
}

View File

@ -1,11 +1,6 @@
package definitions
import (
"fmt"
"html/template"
"regexp"
"strings"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
@ -77,34 +72,3 @@ func (t *MessageTemplate) ResourceType() string {
func (t *MessageTemplate) ResourceID() string {
return t.Name
}
func (t *MessageTemplate) Validate() error {
if t.Name == "" {
return fmt.Errorf("template must have a name")
}
if t.Template == "" {
return fmt.Errorf("template must have content")
}
_, err := template.New("").Parse(t.Template)
if err != nil {
return fmt.Errorf("invalid template: %w", err)
}
content := strings.TrimSpace(t.Template)
found, err := regexp.MatchString(`\{\{\s*define`, content)
if err != nil {
return fmt.Errorf("failed to match regex: %w", err)
}
if !found {
lines := strings.Split(content, "\n")
for i, s := range lines {
lines[i] = " " + s
}
content = strings.Join(lines, "\n")
content = fmt.Sprintf("{{ define \"%s\" }}\n%s\n{{ end }}", t.Name, content)
}
t.Template = content
return nil
}

View File

@ -1439,26 +1439,9 @@
"type": "object",
"x-go-package": "github.com/prometheus/alertmanager/config"
},
"MuteTiming": {
"properties": {
"name": {
"type": "string",
"x-go-name": "Name"
},
"time_intervals": {
"items": {
"$ref": "#/definitions/TimeInterval"
},
"type": "array",
"x-go-name": "TimeIntervals"
}
},
"type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
"MuteTimings": {
"items": {
"$ref": "#/definitions/MuteTiming"
"$ref": "#/definitions/MuteTimeInterval"
},
"type": "array",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@ -3196,11 +3179,12 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
"type": "array"
"type": "array",
"x-go-name": "AlertGroups",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertStatus": {
"description": "AlertStatus alert status",
@ -3445,11 +3429,12 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array"
"type": "array",
"x-go-name": "GettableSilences",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"labelSet": {
"additionalProperties": {
@ -5005,9 +4990,67 @@
"tags": [
"provisioning"
]
},
"post": {
"consumes": [
"application/json"
],
"operationId": "RoutePostMuteTiming",
"parameters": [
{
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
}
],
"responses": {
"201": {
"description": "MuteTimeInterval",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Create a new mute timing.",
"tags": [
"provisioning"
]
}
},
"/api/provisioning/mute-timings/{name}": {
"delete": {
"operationId": "RouteDeleteMuteTiming",
"parameters": [
{
"description": "Template Name",
"in": "path",
"name": "name",
"required": true,
"type": "string",
"x-go-name": "Name"
}
],
"responses": {
"204": {
"description": "Ack",
"schema": {
"$ref": "#/definitions/Ack"
}
}
},
"summary": "Delete a mute timing.",
"tags": [
"provisioning"
]
},
"get": {
"operationId": "RouteGetMuteTiming",
"parameters": [
@ -5022,9 +5065,9 @@
],
"responses": {
"200": {
"description": "MuteTiming",
"description": "MuteTimeInterval",
"schema": {
"$ref": "#/definitions/MuteTiming"
"$ref": "#/definitions/MuteTimeInterval"
}
},
"400": {
@ -5038,6 +5081,47 @@
"tags": [
"provisioning"
]
},
"put": {
"consumes": [
"application/json"
],
"operationId": "RoutePutMuteTiming",
"parameters": [
{
"description": "Template Name",
"in": "path",
"name": "name",
"required": true,
"type": "string",
"x-go-name": "Name"
},
{
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
}
],
"responses": {
"200": {
"description": "MuteTimeInterval",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Replace an existing mute timing.",
"tags": [
"provisioning"
]
}
},
"/api/provisioning/policies": {

View File

@ -1274,6 +1274,39 @@
}
}
}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"provisioning"
],
"summary": "Create a new mute timing.",
"operationId": "RoutePostMuteTiming",
"parameters": [
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
}
],
"responses": {
"201": {
"description": "MuteTimeInterval",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
}
},
"/api/provisioning/mute-timings/{name}": {
@ -1295,9 +1328,9 @@
],
"responses": {
"200": {
"description": "MuteTiming",
"description": "MuteTimeInterval",
"schema": {
"$ref": "#/definitions/MuteTiming"
"$ref": "#/definitions/MuteTimeInterval"
}
},
"400": {
@ -1307,6 +1340,72 @@
}
}
}
},
"put": {
"consumes": [
"application/json"
],
"tags": [
"provisioning"
],
"summary": "Replace an existing mute timing.",
"operationId": "RoutePutMuteTiming",
"parameters": [
{
"type": "string",
"x-go-name": "Name",
"description": "Template Name",
"name": "name",
"in": "path",
"required": true
},
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
}
],
"responses": {
"200": {
"description": "MuteTimeInterval",
"schema": {
"$ref": "#/definitions/MuteTimeInterval"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
},
"delete": {
"tags": [
"provisioning"
],
"summary": "Delete a mute timing.",
"operationId": "RouteDeleteMuteTiming",
"parameters": [
{
"type": "string",
"x-go-name": "Name",
"description": "Template Name",
"name": "name",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Ack",
"schema": {
"$ref": "#/definitions/Ack"
}
}
}
}
},
"/api/provisioning/policies": {
@ -3544,27 +3643,10 @@
},
"x-go-package": "github.com/prometheus/alertmanager/config"
},
"MuteTiming": {
"type": "object",
"properties": {
"name": {
"type": "string",
"x-go-name": "Name"
},
"time_intervals": {
"type": "array",
"items": {
"$ref": "#/definitions/TimeInterval"
},
"x-go-name": "TimeIntervals"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
"MuteTimings": {
"type": "array",
"items": {
"$ref": "#/definitions/MuteTiming"
"$ref": "#/definitions/MuteTimeInterval"
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
@ -5302,11 +5384,12 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
},
"x-go-name": "AlertGroups",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/alertGroups"
},
"alertStatus": {
@ -5555,11 +5638,12 @@
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"x-go-name": "GettableSilences",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableSilences"
},
"labelSet": {

View File

@ -2,9 +2,12 @@ package provisioning
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/prometheus/alertmanager/config"
)
type MuteTimingService struct {
@ -23,19 +26,167 @@ func NewMuteTimingService(config AMConfigStore, prov ProvisioningStore, xact Tra
}
}
func (m *MuteTimingService) GetMuteTimings(ctx context.Context, orgID int64) ([]definitions.MuteTiming, error) {
rev, err := getLastConfiguration(ctx, orgID, m.config)
// GetMuteTimings returns a slice of all mute timings within the specified org.
func (svc *MuteTimingService) GetMuteTimings(ctx context.Context, orgID int64) ([]definitions.MuteTimeInterval, error) {
rev, err := getLastConfiguration(ctx, orgID, svc.config)
if err != nil {
return nil, err
}
if rev.cfg.AlertmanagerConfig.MuteTimeIntervals == nil {
return []definitions.MuteTiming{}, nil
return []definitions.MuteTimeInterval{}, nil
}
result := make([]definitions.MuteTiming, 0, len(rev.cfg.AlertmanagerConfig.MuteTimeIntervals))
result := make([]definitions.MuteTimeInterval, 0, len(rev.cfg.AlertmanagerConfig.MuteTimeIntervals))
for _, interval := range rev.cfg.AlertmanagerConfig.MuteTimeIntervals {
result = append(result, definitions.MuteTiming{MuteTimeInterval: interval})
result = append(result, definitions.MuteTimeInterval{MuteTimeInterval: interval})
}
return result, nil
}
// CreateMuteTiming adds a new mute timing within the specified org. The created mute timing is returned.
func (svc *MuteTimingService) CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (*definitions.MuteTimeInterval, error) {
if err := mt.Validate(); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
revision, err := getLastConfiguration(ctx, orgID, svc.config)
if err != nil {
return nil, err
}
if revision.cfg.AlertmanagerConfig.MuteTimeIntervals == nil {
revision.cfg.AlertmanagerConfig.MuteTimeIntervals = []config.MuteTimeInterval{}
}
for _, existing := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals {
if mt.Name == existing.Name {
return nil, fmt.Errorf("%w: %s", ErrValidation, "a mute timing with this name already exists")
}
}
revision.cfg.AlertmanagerConfig.MuteTimeIntervals = append(revision.cfg.AlertmanagerConfig.MuteTimeIntervals, mt.MuteTimeInterval)
serialized, err := serializeAlertmanagerConfig(*revision.cfg)
if err != nil {
return nil, err
}
cmd := models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(serialized),
ConfigurationVersion: revision.version,
FetchedConfigurationHash: revision.concurrencyToken,
Default: false,
OrgID: orgID,
}
err = svc.xact.InTransaction(ctx, func(ctx context.Context) error {
err = svc.config.UpdateAlertmanagerConfiguration(ctx, &cmd)
if err != nil {
return err
}
err = svc.prov.SetProvenance(ctx, &mt, orgID, mt.Provenance)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &mt, nil
}
// UpdateMuteTiming replaces an existing mute timing within the specified org. The replaced mute timing is returned. If the mute timing does not exist, nil is returned and no action is taken.
func (svc *MuteTimingService) UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (*definitions.MuteTimeInterval, error) {
if err := mt.Validate(); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
revision, err := getLastConfiguration(ctx, orgID, svc.config)
if err != nil {
return nil, err
}
if revision.cfg.AlertmanagerConfig.MuteTimeIntervals == nil {
return nil, nil
}
updated := false
for i, existing := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals {
if mt.Name == existing.Name {
revision.cfg.AlertmanagerConfig.MuteTimeIntervals[i] = mt.MuteTimeInterval
updated = true
break
}
}
if !updated {
return nil, nil
}
serialized, err := serializeAlertmanagerConfig(*revision.cfg)
if err != nil {
return nil, err
}
cmd := models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(serialized),
ConfigurationVersion: revision.version,
FetchedConfigurationHash: revision.concurrencyToken,
Default: false,
OrgID: orgID,
}
err = svc.xact.InTransaction(ctx, func(ctx context.Context) error {
err = svc.config.UpdateAlertmanagerConfiguration(ctx, &cmd)
if err != nil {
return err
}
err = svc.prov.SetProvenance(ctx, &mt, orgID, mt.Provenance)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &mt, err
}
// DeleteMuteTiming deletes the mute timing with the given name in the given org. If the mute timing does not exist, no error is returned.
func (svc *MuteTimingService) DeleteMuteTiming(ctx context.Context, name string, orgID int64) error {
revision, err := getLastConfiguration(ctx, orgID, svc.config)
if err != nil {
return err
}
if revision.cfg.AlertmanagerConfig.MuteTimeIntervals == nil {
return nil
}
for i, existing := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals {
if name == existing.Name {
intervals := revision.cfg.AlertmanagerConfig.MuteTimeIntervals
revision.cfg.AlertmanagerConfig.MuteTimeIntervals = append(intervals[:i], intervals[i+1:]...)
}
}
serialized, err := serializeAlertmanagerConfig(*revision.cfg)
if err != nil {
return err
}
cmd := models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(serialized),
ConfigurationVersion: revision.version,
FetchedConfigurationHash: revision.concurrencyToken,
Default: false,
OrgID: orgID,
}
return svc.xact.InTransaction(ctx, func(ctx context.Context) error {
err = svc.config.UpdateAlertmanagerConfiguration(ctx, &cmd)
if err != nil {
return err
}
target := definitions.MuteTimeInterval{MuteTimeInterval: config.MuteTimeInterval{Name: name}}
err := svc.prov.DeleteProvenance(ctx, &target, orgID)
if err != nil {
return err
}
return nil
})
}

View File

@ -6,7 +6,9 @@ import (
"testing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/prometheus/alertmanager/config"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@ -74,6 +76,288 @@ func TestMuteTimingService(t *testing.T) {
require.ErrorContains(t, err, "no alertmanager configuration")
})
})
t.Run("creating mute timings", func(t *testing.T) {
t.Run("rejects mute timings that fail validation", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := definitions.MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "",
},
}
_, err := sut.CreateMuteTiming(context.Background(), timing, 1)
require.ErrorIs(t, err, ErrValidation)
})
t.Run("propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed"))
_, err := sut.CreateMuteTiming(context.Background(), timing, 1)
require.Error(t, err)
})
t.Run("when config is invalid", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: brokenConfig,
})
_, err := sut.CreateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "failed to deserialize")
})
t.Run("when no AM config in current org", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(nil)
_, err := sut.CreateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "no alertmanager configuration")
})
t.Run("when provenance fails to save", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds()
sut.prov.(*MockProvisioningStore).EXPECT().
SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(fmt.Errorf("failed to save provenance"))
_, err := sut.CreateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "failed to save provenance")
})
t.Run("when AM config fails to save", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().
UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed to save config"))
sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds()
_, err := sut.CreateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "failed to save config")
})
})
})
t.Run("updating mute timings", func(t *testing.T) {
t.Run("rejects mute timings that fail validation", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := definitions.MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "",
},
}
_, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.ErrorIs(t, err, ErrValidation)
})
t.Run("returns nil if timing does not exist", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
timing.Name = "does not exist"
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds()
sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds()
updated, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.NoError(t, err)
require.Nil(t, updated)
})
t.Run("propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
timing.Name = "asdf"
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed"))
_, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.Error(t, err)
})
t.Run("when config is invalid", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
timing.Name = "asdf"
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: brokenConfig,
})
_, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "failed to deserialize")
})
t.Run("when no AM config in current org", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
timing.Name = "asdf"
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(nil)
_, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "no alertmanager configuration")
})
t.Run("when provenance fails to save", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
timing.Name = "asdf"
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds()
sut.prov.(*MockProvisioningStore).EXPECT().
SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(fmt.Errorf("failed to save provenance"))
_, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "failed to save provenance")
})
t.Run("when AM config fails to save", func(t *testing.T) {
sut := createMuteTimingSvcSut()
timing := createMuteTiming()
timing.Name = "asdf"
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().
UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed to save config"))
sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds()
_, err := sut.UpdateMuteTiming(context.Background(), timing, 1)
require.ErrorContains(t, err, "failed to save config")
})
})
})
t.Run("deleting mute timings", func(t *testing.T) {
t.Run("returns nil if timing does not exist", func(t *testing.T) {
sut := createMuteTimingSvcSut()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds()
sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds()
err := sut.DeleteMuteTiming(context.Background(), "does not exist", 1)
require.NoError(t, err)
})
t.Run("propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut := createMuteTimingSvcSut()
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed"))
err := sut.DeleteMuteTiming(context.Background(), "asdf", 1)
require.Error(t, err)
})
t.Run("when config is invalid", func(t *testing.T) {
sut := createMuteTimingSvcSut()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: brokenConfig,
})
err := sut.DeleteMuteTiming(context.Background(), "asdf", 1)
require.ErrorContains(t, err, "failed to deserialize")
})
t.Run("when no AM config in current org", func(t *testing.T) {
sut := createMuteTimingSvcSut()
sut.config.(*MockAMConfigStore).EXPECT().
GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(nil)
err := sut.DeleteMuteTiming(context.Background(), "asdf", 1)
require.ErrorContains(t, err, "no alertmanager configuration")
})
t.Run("when provenance fails to save", func(t *testing.T) {
sut := createMuteTimingSvcSut()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds()
sut.prov.(*MockProvisioningStore).EXPECT().
DeleteProvenance(mock.Anything, mock.Anything, mock.Anything).
Return(fmt.Errorf("failed to save provenance"))
err := sut.DeleteMuteTiming(context.Background(), "asdf", 1)
require.ErrorContains(t, err, "failed to save provenance")
})
t.Run("when AM config fails to save", func(t *testing.T) {
sut := createMuteTimingSvcSut()
sut.config.(*MockAMConfigStore).EXPECT().
getsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: configWithMuteTimings,
})
sut.config.(*MockAMConfigStore).EXPECT().
UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything).
Return(fmt.Errorf("failed to save config"))
sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds()
err := sut.DeleteMuteTiming(context.Background(), "asdf", 1)
require.ErrorContains(t, err, "failed to save config")
})
})
})
}
func createMuteTimingSvcSut() *MuteTimingService {
@ -85,6 +369,14 @@ func createMuteTimingSvcSut() *MuteTimingService {
}
}
func createMuteTiming() definitions.MuteTimeInterval {
return definitions.MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: "interval",
},
}
}
var configWithMuteTimings = `
{
"template_files": {