From 7e4327d254424a3ef8a83cf34d58a107f4349571 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Thu, 26 May 2022 17:06:52 -0400 Subject: [PATCH] Alerting: Endpoints for provisioning mute timings (#49635) (#49735) * 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 (cherry picked from commit 909ebcf979514f43798893759cb00762d0b90c44) Co-authored-by: Alexander Weaver --- pkg/services/ngalert/api/api_provisioning.go | 64 +++- pkg/services/ngalert/api/authorization.go | 5 +- .../ngalert/api/forked_provisioning.go | 12 + .../api/generated_base_api_provisioning.go | 53 ++++ pkg/services/ngalert/api/tooling/api.json | 34 +- .../api/tooling/definitions/alertmanager.go | 14 + .../definitions/alertmanager_validation.go | 46 +++ .../alertmanager_validation_test.go | 110 +++++++ .../definitions/provisioning_mute_timings.go | 48 ++- .../definitions/provisioning_templates.go | 36 --- pkg/services/ngalert/api/tooling/post.json | 132 ++++++-- pkg/services/ngalert/api/tooling/spec.json | 128 ++++++-- .../ngalert/provisioning/mute_timings.go | 161 +++++++++- .../ngalert/provisioning/mute_timings_test.go | 292 ++++++++++++++++++ 14 files changed, 1003 insertions(+), 132 deletions(-) diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 3bcd608b906..1edcb98d555 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -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] +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 70ff0034dd3..dc6725d0efc 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -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 } diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go index 6705c5ba176..297147e7e65 100644 --- a/pkg/services/ngalert/api/forked_provisioning.go +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -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) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index 66881649af6..edde8b0afd1 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -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"), diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index dc8dd4dc60b..fd722d9ef11 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -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", diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 7b525a07478..fa27afd52f3 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -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"` diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go index a4a205d155d..5b6365ea412 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go @@ -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 +} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go index 5cc40ec9410..d0d3de03719 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go @@ -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) + }) + } + }) +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go index c5720206e82..761fec0f9f5 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go @@ -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 +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go index 8ec4210721f..479f192c6be 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go @@ -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 -} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index a3c445e9374..a4b6a1cae67 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -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": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index d10fd315e2e..eaec39f99c5 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -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": { diff --git a/pkg/services/ngalert/provisioning/mute_timings.go b/pkg/services/ngalert/provisioning/mute_timings.go index a2f08683d70..0df16beada4 100644 --- a/pkg/services/ngalert/provisioning/mute_timings.go +++ b/pkg/services/ngalert/provisioning/mute_timings.go @@ -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 + }) +} diff --git a/pkg/services/ngalert/provisioning/mute_timings_test.go b/pkg/services/ngalert/provisioning/mute_timings_test.go index 6a1a9be329c..9a6b6e90853 100644 --- a/pkg/services/ngalert/provisioning/mute_timings_test.go +++ b/pkg/services/ngalert/provisioning/mute_timings_test.go @@ -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": {