diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 2ae326d1436..663a6c38f50 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -133,6 +133,16 @@ func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *contextmodel.ReqContext) re return response.JSON(http.StatusOK, config) } +func (srv AlertmanagerSrv) RouteGetAlertingConfigHistory(c *contextmodel.ReqContext) response.Response { + limit := c.QueryInt("limit") + configs, err := srv.mam.GetAppliedAlertmanagerConfigurations(c.Req.Context(), c.OrgID, limit) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, err.Error()) + } + + return response.JSON(http.StatusOK, configs) +} + func (srv AlertmanagerSrv) RouteGetAMAlertGroups(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.OrgID) if errResp != nil { diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index 6b253b0a029..e5e3b6f2160 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -291,6 +291,96 @@ func TestAlertmanagerConfig(t *testing.T) { }) } +func TestRouteGetAlertingConfigHistory(t *testing.T) { + sut := createSut(t, nil) + + t.Run("assert 200 and empty slice when no applied configurations are found", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + q.Add("limit", "10") + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(10) + + response := sut.RouteGetAlertingConfigHistory(rc) + require.Equal(tt, 200, response.Status()) + + var configs []apimodels.GettableHistoricUserConfig + err = json.Unmarshal(response.Body(), &configs) + require.NoError(tt, err) + + require.Len(tt, configs, 0) + }) + + t.Run("assert 200 and one config in the response for an org that has one successfully applied configuration", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + q.Add("limit", "10") + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(1) + + response := sut.RouteGetAlertingConfigHistory(rc) + require.Equal(tt, 200, response.Status()) + + var configs []apimodels.GettableHistoricUserConfig + err = json.Unmarshal(response.Body(), &configs) + require.NoError(tt, err) + + require.Len(tt, configs, 1) + }) + + t.Run("assert 200 when no limit is provided", func(tt *testing.T) { + rc := createRequestCtxInOrg(1) + + response := sut.RouteGetAlertingConfigHistory(rc) + require.Equal(tt, 200, response.Status()) + + configs := asGettableHistoricUserConfigs(tt, response) + for _, config := range configs { + require.NotZero(tt, config.LastApplied) + } + }) + + t.Run("assert 200 when limit is < 1", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + q.Add("limit", "0") + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(1) + + response := sut.RouteGetAlertingConfigHistory(rc) + require.Equal(tt, 200, response.Status()) + + configs := asGettableHistoricUserConfigs(tt, response) + for _, config := range configs { + require.NotZero(tt, config.LastApplied) + } + }) + + t.Run("assert 200 when limit is > 100", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + q.Add("limit", "1000") + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(1) + + response := sut.RouteGetAlertingConfigHistory(rc) + require.Equal(tt, 200, response.Status()) + + configs := asGettableHistoricUserConfigs(tt, response) + for _, config := range configs { + require.NotZero(tt, config.LastApplied) + } + }) +} + func TestSilenceCreate(t *testing.T) { makeSilence := func(comment string, createdBy string, startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence { @@ -661,3 +751,11 @@ func asGettableUserConfig(t *testing.T, r response.Response) *apimodels.Gettable require.NoError(t, err) return body } + +func asGettableHistoricUserConfigs(t *testing.T, r response.Response) []apimodels.GettableHistoricUserConfig { + t.Helper() + var body []apimodels.GettableHistoricUserConfig + err := json.Unmarshal(r.Body(), &body) + require.NoError(t, err) + return body +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 2a9ffbcb159..8157ea0ebf1 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -158,6 +158,9 @@ func (api *API) authorize(method, path string) web.Handler { case http.MethodGet + "/api/alertmanager/grafana/config/api/v1/alerts": fallback = middleware.ReqEditorRole eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead) + case http.MethodGet + "/api/alertmanager/grafana/config/history": + fallback = middleware.ReqEditorRole + eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead) case http.MethodGet + "/api/alertmanager/grafana/api/v2/status": eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead) case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/alerts": diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 7fa81720d3d..e9cdab1a24a 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 45) + require.Len(t, paths, 46) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/forking_alertmanager.go b/pkg/services/ngalert/api/forking_alertmanager.go index a45f35c9a98..a1762ee64fb 100644 --- a/pkg/services/ngalert/api/forking_alertmanager.go +++ b/pkg/services/ngalert/api/forking_alertmanager.go @@ -159,6 +159,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAlertingConfig(ctx *contex return f.GrafanaSvc.RouteGetAlertingConfig(ctx) } +func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAlertingConfigHistory(ctx *contextmodel.ReqContext) response.Response { + return f.GrafanaSvc.RouteGetAlertingConfigHistory(ctx) +} + func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilence(ctx *contextmodel.ReqContext, id string) response.Response { return f.GrafanaSvc.RouteGetSilence(ctx, id) } diff --git a/pkg/services/ngalert/api/generated_base_api_alertmanager.go b/pkg/services/ngalert/api/generated_base_api_alertmanager.go index 65ec0ad8b5a..d474837b9ec 100644 --- a/pkg/services/ngalert/api/generated_base_api_alertmanager.go +++ b/pkg/services/ngalert/api/generated_base_api_alertmanager.go @@ -33,6 +33,7 @@ type AlertmanagerApi interface { RouteGetGrafanaAMAlerts(*contextmodel.ReqContext) response.Response RouteGetGrafanaAMStatus(*contextmodel.ReqContext) response.Response RouteGetGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response + RouteGetGrafanaAlertingConfigHistory(*contextmodel.ReqContext) response.Response RouteGetGrafanaReceivers(*contextmodel.ReqContext) response.Response RouteGetGrafanaSilence(*contextmodel.ReqContext) response.Response RouteGetGrafanaSilences(*contextmodel.ReqContext) response.Response @@ -113,6 +114,9 @@ func (f *AlertmanagerApiHandler) RouteGetGrafanaAMStatus(ctx *contextmodel.ReqCo func (f *AlertmanagerApiHandler) RouteGetGrafanaAlertingConfig(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaAlertingConfig(ctx) } +func (f *AlertmanagerApiHandler) RouteGetGrafanaAlertingConfigHistory(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteGetGrafanaAlertingConfigHistory(ctx) +} func (f *AlertmanagerApiHandler) RouteGetGrafanaReceivers(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetGrafanaReceivers(ctx) } @@ -314,6 +318,16 @@ func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApi, m *metrics m, ), ) + group.Get( + toMacaronPath("/api/alertmanager/grafana/config/history"), + api.authorize(http.MethodGet, "/api/alertmanager/grafana/config/history"), + metrics.Instrument( + http.MethodGet, + "/api/alertmanager/grafana/config/history", + srv.RouteGetGrafanaAlertingConfigHistory, + m, + ), + ) group.Get( toMacaronPath("/api/alertmanager/grafana/config/api/v1/receivers"), api.authorize(http.MethodGet, "/api/alertmanager/grafana/config/api/v1/receivers"), diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 779343042be..4f5222b196c 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -185,9 +185,9 @@ }, "execErrState": { "enum": [ + "OK", "Alerting", - "Error", - "OK" + "Error" ], "type": "string" }, @@ -528,6 +528,9 @@ "DataLink": { "description": "DataLink define what", "properties": { + "internal": { + "$ref": "#/definitions/InternalDataLink" + }, "targetBlank": { "type": "boolean" }, @@ -715,6 +718,40 @@ ], "type": "object" }, + "EnumFieldConfig": { + "description": "Enum field config\nVector values are used as lookup keys into the enum fields", + "properties": { + "color": { + "description": "Color is the color value for a given index (empty is undefined)", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Description of the enum state", + "items": { + "type": "string" + }, + "type": "array" + }, + "icon": { + "description": "Icon supports setting an icon for a given index value", + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "description": "Value is the string display value for a given index", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ErrorType": { "title": "ErrorType models the different API error types.", "type": "string" @@ -754,6 +791,9 @@ "type": "object" }, "EvalQueriesResponse": {}, + "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" + }, "ExtendedReceiver": { "properties": { "email_configs": { @@ -870,6 +910,9 @@ "thresholds": { "$ref": "#/definitions/ThresholdsConfig" }, + "type": { + "$ref": "#/definitions/FieldTypeConfig" + }, "unit": { "description": "Numeric Options", "type": "string" @@ -882,11 +925,20 @@ "title": "FieldConfig represents the display properties for a Field.", "type": "object" }, + "FieldTypeConfig": { + "description": "FieldTypeConfig has type specific configs, only one should be active at a time", + "properties": { + "enum": { + "$ref": "#/definitions/EnumFieldConfig" + } + }, + "type": "object" + }, "Frame": { "description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.", "properties": { "Fields": { - "description": "Fields are the columns of a frame.\nAll Fields must be of the same the length when marshalling the Frame for transmission.", + "description": "Fields are the columns of a frame.\nAll Fields must be of the same the length when marshalling the Frame for transmission.\nThere should be no `nil` entries in the Fields slice (making them pointers was a mistake).", "items": { "$ref": "#/definitions/Field" }, @@ -958,6 +1010,9 @@ }, "type": { "$ref": "#/definitions/FrameType" + }, + "typeVersion": { + "$ref": "#/definitions/FrameTypeVersion" } }, "title": "FrameMeta matches:", @@ -967,8 +1022,16 @@ "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", "type": "string" }, + "FrameTypeVersion": { + "items": { + "format": "uint64", + "type": "integer" + }, + "title": "FrameType is a 2 number version (Major / Minor).", + "type": "array" + }, "Frames": { - "description": "It is the main data container within a backend.DataResponse.", + "description": "It is the main data container within a backend.DataResponse.\nThere should be no `nil` entries in the Frames slice (making them pointers was a mistake).", "items": { "$ref": "#/definitions/Frame" }, @@ -1259,6 +1322,34 @@ }, "type": "object" }, + "GettableHistoricUserConfig": { + "properties": { + "alertmanager_config": { + "$ref": "#/definitions/GettableApiAlertingConfig" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_applied": { + "format": "date-time", + "type": "string" + }, + "template_file_provenances": { + "additionalProperties": { + "$ref": "#/definitions/Provenance" + }, + "type": "object" + }, + "template_files": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, "GettableNGalertConfig": { "properties": { "alertmanagersChoice": { @@ -1521,6 +1612,31 @@ "title": "InspectType is a type for the Inspect property of a Notice.", "type": "integer" }, + "InternalDataLink": { + "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", + "properties": { + "datasourceName": { + "type": "string" + }, + "datasourceUid": { + "type": "string" + }, + "panelsState": { + "$ref": "#/definitions/ExplorePanelsState" + }, + "query": {}, + "timeRange": { + "$ref": "#/definitions/TimeRange" + }, + "transformations": { + "items": { + "$ref": "#/definitions/LinkTransformationConfig" + }, + "type": "array" + } + }, + "type": "object" + }, "Json": { "type": "object" }, @@ -1562,6 +1678,23 @@ }, "type": "array" }, + "LinkTransformationConfig": { + "properties": { + "expression": { + "type": "string" + }, + "field": { + "type": "string" + }, + "mapValue": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/SupportedTransformationTypes" + } + }, + "type": "object" + }, "MatchRegexps": { "additionalProperties": { "$ref": "#/definitions/Regexp" @@ -2272,9 +2405,9 @@ }, "execErrState": { "enum": [ + "OK", "Alerting", - "Error", - "OK" + "Error" ], "type": "string" }, @@ -2472,6 +2605,9 @@ "thresholds": { "$ref": "#/definitions/ThresholdsConfig" }, + "type": { + "$ref": "#/definitions/FieldTypeConfig" + }, "unit": { "description": "Numeric Options", "type": "string" @@ -3040,6 +3176,9 @@ "Success": { "$ref": "#/definitions/ResponseDetails" }, + "SupportedTransformationTypes": { + "type": "string" + }, "TLSConfig": { "properties": { "ca_file": { @@ -3279,22 +3418,20 @@ "type": "object" }, "TimeRange": { - "description": "For example, 4:00PM to End of the day would Begin at 1020 and End at 1440.", + "description": "Redefining this to avoid an import cycle", "properties": { - "EndMinute": { - "format": "int64", - "type": "integer" + "from": { + "format": "date-time", + "type": "string" }, - "StartMinute": { - "format": "int64", - "type": "integer" + "to": { + "format": "date-time", + "type": "string" } }, - "title": "TimeRange represents a range of minutes within a 1440 minute day, exclusive of the End minute. A day consists of 1440 minutes.", "type": "object" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -3330,7 +3467,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "type": "object" }, "Userinfo": { @@ -3530,7 +3667,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -3894,6 +4030,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3931,6 +4068,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -4864,6 +5002,15 @@ "application/json" ], "responses": { + "GettableHistoricUserConfigs": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableHistoricUserConfig" + }, + "type": "array" + } + }, "receiversResponse": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 29b685d3ea0..da55966efdd 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -54,6 +54,13 @@ import ( // 400: ValidationError // 404: NotFound +// swagger:route GET /api/alertmanager/grafana/config/history alertmanager RouteGetGrafanaAlertingConfigHistory +// +// gets Alerting configurations that were successfully applied in the past +// +// Responses: +// 200: GettableHistoricUserConfigs + // swagger:route DELETE /api/alertmanager/grafana/config/api/v1/alerts alertmanager RouteDeleteGrafanaAlertingConfig // // deletes the Alerting config for a tenant @@ -229,6 +236,13 @@ type AlertManagerNotReady struct{} // swagger:model type MultiStatus struct{} +// swagger:parameters RouteGetGrafanaAlertingConfigHistory +type RouteGetGrafanaAlertingConfigHistoryParams struct { + // Limit response to n historic configurations. + // in:query + Limit int `json:"limit"` +} + // swagger:parameters RoutePostTestGrafanaReceivers type TestReceiversConfigParams struct { // in:body @@ -633,6 +647,20 @@ func (c *GettableUserConfig) GetGrafanaReceiverMap() map[string]*GettableGrafana return UIDs } +type GettableHistoricUserConfig struct { + ID int64 `yaml:"id" json:"id"` + TemplateFiles map[string]string `yaml:"template_files" json:"template_files"` + TemplateFileProvenances map[string]Provenance `yaml:"template_file_provenances,omitempty" json:"template_file_provenances,omitempty"` + AlertmanagerConfig GettableApiAlertingConfig `yaml:"alertmanager_config" json:"alertmanager_config"` + LastApplied *strfmt.DateTime `yaml:"last_applied,omitempty" json:"last_applied,omitempty"` +} + +// swagger:response GettableHistoricUserConfigs +type GettableHistoricUserConfigs struct { + // in:body + Body []GettableHistoricUserConfig +} + type GettableApiAlertingConfig struct { Config `yaml:",inline"` MuteTimeProvenances map[string]Provenance `yaml:"muteTimeProvenances,omitempty" json:"muteTimeProvenances,omitempty"` diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 8b9565b87c9..206e5e4e113 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -185,9 +185,9 @@ }, "execErrState": { "enum": [ + "OK", "Alerting", - "Error", - "OK" + "Error" ], "type": "string" }, @@ -528,6 +528,9 @@ "DataLink": { "description": "DataLink define what", "properties": { + "internal": { + "$ref": "#/definitions/InternalDataLink" + }, "targetBlank": { "type": "boolean" }, @@ -715,6 +718,40 @@ ], "type": "object" }, + "EnumFieldConfig": { + "description": "Enum field config\nVector values are used as lookup keys into the enum fields", + "properties": { + "color": { + "description": "Color is the color value for a given index (empty is undefined)", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Description of the enum state", + "items": { + "type": "string" + }, + "type": "array" + }, + "icon": { + "description": "Icon supports setting an icon for a given index value", + "items": { + "type": "string" + }, + "type": "array" + }, + "text": { + "description": "Value is the string display value for a given index", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ErrorType": { "title": "ErrorType models the different API error types.", "type": "string" @@ -754,6 +791,9 @@ "type": "object" }, "EvalQueriesResponse": {}, + "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" + }, "ExtendedReceiver": { "properties": { "email_configs": { @@ -870,6 +910,9 @@ "thresholds": { "$ref": "#/definitions/ThresholdsConfig" }, + "type": { + "$ref": "#/definitions/FieldTypeConfig" + }, "unit": { "description": "Numeric Options", "type": "string" @@ -882,11 +925,20 @@ "title": "FieldConfig represents the display properties for a Field.", "type": "object" }, + "FieldTypeConfig": { + "description": "FieldTypeConfig has type specific configs, only one should be active at a time", + "properties": { + "enum": { + "$ref": "#/definitions/EnumFieldConfig" + } + }, + "type": "object" + }, "Frame": { "description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.", "properties": { "Fields": { - "description": "Fields are the columns of a frame.\nAll Fields must be of the same the length when marshalling the Frame for transmission.", + "description": "Fields are the columns of a frame.\nAll Fields must be of the same the length when marshalling the Frame for transmission.\nThere should be no `nil` entries in the Fields slice (making them pointers was a mistake).", "items": { "$ref": "#/definitions/Field" }, @@ -958,6 +1010,9 @@ }, "type": { "$ref": "#/definitions/FrameType" + }, + "typeVersion": { + "$ref": "#/definitions/FrameTypeVersion" } }, "title": "FrameMeta matches:", @@ -967,8 +1022,16 @@ "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", "type": "string" }, + "FrameTypeVersion": { + "items": { + "format": "uint64", + "type": "integer" + }, + "title": "FrameType is a 2 number version (Major / Minor).", + "type": "array" + }, "Frames": { - "description": "It is the main data container within a backend.DataResponse.", + "description": "It is the main data container within a backend.DataResponse.\nThere should be no `nil` entries in the Frames slice (making them pointers was a mistake).", "items": { "$ref": "#/definitions/Frame" }, @@ -1259,6 +1322,34 @@ }, "type": "object" }, + "GettableHistoricUserConfig": { + "properties": { + "alertmanager_config": { + "$ref": "#/definitions/GettableApiAlertingConfig" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "last_applied": { + "format": "date-time", + "type": "string" + }, + "template_file_provenances": { + "additionalProperties": { + "$ref": "#/definitions/Provenance" + }, + "type": "object" + }, + "template_files": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, "GettableNGalertConfig": { "properties": { "alertmanagersChoice": { @@ -1521,6 +1612,31 @@ "title": "InspectType is a type for the Inspect property of a Notice.", "type": "integer" }, + "InternalDataLink": { + "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", + "properties": { + "datasourceName": { + "type": "string" + }, + "datasourceUid": { + "type": "string" + }, + "panelsState": { + "$ref": "#/definitions/ExplorePanelsState" + }, + "query": {}, + "timeRange": { + "$ref": "#/definitions/TimeRange" + }, + "transformations": { + "items": { + "$ref": "#/definitions/LinkTransformationConfig" + }, + "type": "array" + } + }, + "type": "object" + }, "Json": { "type": "object" }, @@ -1562,6 +1678,23 @@ }, "type": "array" }, + "LinkTransformationConfig": { + "properties": { + "expression": { + "type": "string" + }, + "field": { + "type": "string" + }, + "mapValue": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/SupportedTransformationTypes" + } + }, + "type": "object" + }, "MatchRegexps": { "additionalProperties": { "$ref": "#/definitions/Regexp" @@ -2272,9 +2405,9 @@ }, "execErrState": { "enum": [ + "OK", "Alerting", - "Error", - "OK" + "Error" ], "type": "string" }, @@ -2472,6 +2605,9 @@ "thresholds": { "$ref": "#/definitions/ThresholdsConfig" }, + "type": { + "$ref": "#/definitions/FieldTypeConfig" + }, "unit": { "description": "Numeric Options", "type": "string" @@ -3040,6 +3176,9 @@ "Success": { "$ref": "#/definitions/ResponseDetails" }, + "SupportedTransformationTypes": { + "type": "string" + }, "TLSConfig": { "properties": { "ca_file": { @@ -3279,22 +3418,21 @@ "type": "object" }, "TimeRange": { - "description": "For example, 4:00PM to End of the day would Begin at 1020 and End at 1440.", + "description": "Redefining this to avoid an import cycle", "properties": { - "EndMinute": { - "format": "int64", - "type": "integer" + "from": { + "format": "date-time", + "type": "string" }, - "StartMinute": { - "format": "int64", - "type": "integer" + "to": { + "format": "date-time", + "type": "string" } }, - "title": "TimeRange represents a range of minutes within a 1440 minute day, exclusive of the End minute. A day consists of 1440 minutes.", "type": "object" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -3635,7 +3773,6 @@ "type": "object" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3697,7 +3834,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3746,14 +3882,12 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, "type": "array" }, "integration": { - "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -3897,6 +4031,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4471,6 +4606,29 @@ ] } }, + "/api/alertmanager/grafana/config/history": { + "get": { + "description": "gets Alerting configurations that were successfully applied in the past", + "operationId": "RouteGetGrafanaAlertingConfigHistory", + "parameters": [ + { + "description": "Limit response to n historic configurations.", + "format": "int64", + "in": "query", + "name": "limit", + "type": "integer" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GettableHistoricUserConfigs" + } + }, + "tags": [ + "alertmanager" + ] + } + }, "/api/alertmanager/{DatasourceUID}/api/v2/alerts": { "get": { "description": "get alertmanager alerts", @@ -6665,6 +6823,15 @@ "application/json" ], "responses": { + "GettableHistoricUserConfigs": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableHistoricUserConfig" + }, + "type": "array" + } + }, "receiversResponse": { "description": "", "schema": { @@ -6685,4 +6852,4 @@ } }, "swagger": "2.0" -} +} \ No newline at end of file diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 5f16d5c6943..aebd5a0bf1c 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -435,6 +435,29 @@ } } }, + "/api/alertmanager/grafana/config/history": { + "get": { + "description": "gets Alerting configurations that were successfully applied in the past", + "tags": [ + "alertmanager" + ], + "operationId": "RouteGetGrafanaAlertingConfigHistory", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Limit response to n historic configurations.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GettableHistoricUserConfigs" + } + } + } + }, "/api/alertmanager/{DatasourceUID}/api/v2/alerts": { "get": { "description": "get alertmanager alerts", @@ -2836,9 +2859,9 @@ "execErrState": { "type": "string", "enum": [ + "OK", "Alerting", - "Error", - "OK" + "Error" ] }, "for": { @@ -2942,8 +2965,7 @@ "$ref": "#/definitions/AlertRuleGroupExport" } } - }, - "$ref": "#/definitions/AlertingFileExport" + } }, "AlertingRule": { "description": "adapted from cortex", @@ -3178,6 +3200,9 @@ "description": "DataLink define what", "type": "object", "properties": { + "internal": { + "$ref": "#/definitions/InternalDataLink" + }, "targetBlank": { "type": "boolean" }, @@ -3365,6 +3390,40 @@ } } }, + "EnumFieldConfig": { + "description": "Enum field config\nVector values are used as lookup keys into the enum fields", + "type": "object", + "properties": { + "color": { + "description": "Color is the color value for a given index (empty is undefined)", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "description": "Description of the enum state", + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "description": "Icon supports setting an icon for a given index value", + "type": "array", + "items": { + "type": "string" + } + }, + "text": { + "description": "Value is the string display value for a given index", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ErrorType": { "type": "string", "title": "ErrorType models the different API error types." @@ -3406,6 +3465,9 @@ "EvalQueriesResponse": { "$ref": "#/definitions/EvalQueriesResponse" }, + "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" + }, "ExtendedReceiver": { "type": "object", "properties": { @@ -3524,6 +3586,9 @@ "thresholds": { "$ref": "#/definitions/ThresholdsConfig" }, + "type": { + "$ref": "#/definitions/FieldTypeConfig" + }, "unit": { "description": "Numeric Options", "type": "string" @@ -3534,13 +3599,22 @@ } } }, + "FieldTypeConfig": { + "description": "FieldTypeConfig has type specific configs, only one should be active at a time", + "type": "object", + "properties": { + "enum": { + "$ref": "#/definitions/EnumFieldConfig" + } + } + }, "Frame": { "description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.", "type": "object", "title": "Frame is a columnar data structure where each column is a Field.", "properties": { "Fields": { - "description": "Fields are the columns of a frame.\nAll Fields must be of the same the length when marshalling the Frame for transmission.", + "description": "Fields are the columns of a frame.\nAll Fields must be of the same the length when marshalling the Frame for transmission.\nThere should be no `nil` entries in the Fields slice (making them pointers was a mistake).", "type": "array", "items": { "$ref": "#/definitions/Field" @@ -3612,6 +3686,9 @@ }, "type": { "$ref": "#/definitions/FrameType" + }, + "typeVersion": { + "$ref": "#/definitions/FrameTypeVersion" } } }, @@ -3619,8 +3696,16 @@ "description": "A FrameType string, when present in a frame's metadata, asserts that the\nframe's structure conforms to the FrameType's specification.\nThis property is currently optional, so FrameType may be FrameTypeUnknown even if the properties of\nthe Frame correspond to a defined FrameType.", "type": "string" }, + "FrameTypeVersion": { + "type": "array", + "title": "FrameType is a 2 number version (Major / Minor).", + "items": { + "type": "integer", + "format": "uint64" + } + }, "Frames": { - "description": "It is the main data container within a backend.DataResponse.", + "description": "It is the main data container within a backend.DataResponse.\nThere should be no `nil` entries in the Frames slice (making them pointers was a mistake).", "type": "array", "title": "Frames is a slice of Frame pointers.", "items": { @@ -3911,6 +3996,34 @@ } } }, + "GettableHistoricUserConfig": { + "type": "object", + "properties": { + "alertmanager_config": { + "$ref": "#/definitions/GettableApiAlertingConfig" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "last_applied": { + "type": "string", + "format": "date-time" + }, + "template_file_provenances": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Provenance" + } + }, + "template_files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "GettableNGalertConfig": { "type": "object", "properties": { @@ -4173,6 +4286,31 @@ "format": "int64", "title": "InspectType is a type for the Inspect property of a Notice." }, + "InternalDataLink": { + "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", + "type": "object", + "properties": { + "datasourceName": { + "type": "string" + }, + "datasourceUid": { + "type": "string" + }, + "panelsState": { + "$ref": "#/definitions/ExplorePanelsState" + }, + "query": {}, + "timeRange": { + "$ref": "#/definitions/TimeRange" + }, + "transformations": { + "type": "array", + "items": { + "$ref": "#/definitions/LinkTransformationConfig" + } + } + } + }, "Json": { "type": "object" }, @@ -4214,6 +4352,23 @@ "$ref": "#/definitions/Label" } }, + "LinkTransformationConfig": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "field": { + "type": "string" + }, + "mapValue": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/SupportedTransformationTypes" + } + } + }, "MatchRegexps": { "type": "object", "title": "MatchRegexps represents a map of Regexp.", @@ -4938,9 +5093,9 @@ "execErrState": { "type": "string", "enum": [ + "OK", "Alerting", - "Error", - "OK" + "Error" ] }, "folderUID": { @@ -5127,6 +5282,9 @@ "thresholds": { "$ref": "#/definitions/ThresholdsConfig" }, + "type": { + "$ref": "#/definitions/FieldTypeConfig" + }, "unit": { "description": "Numeric Options", "type": "string" @@ -5693,6 +5851,9 @@ "Success": { "$ref": "#/definitions/ResponseDetails" }, + "SupportedTransformationTypes": { + "type": "string" + }, "TLSConfig": { "type": "object", "title": "TLSConfig configures the options for TLS connections.", @@ -5932,22 +6093,21 @@ } }, "TimeRange": { - "description": "For example, 4:00PM to End of the day would Begin at 1020 and End at 1440.", + "description": "Redefining this to avoid an import cycle", "type": "object", - "title": "TimeRange represents a range of minutes within a 1440 minute day, exclusive of the End minute. A day consists of 1440 minutes.", "properties": { - "EndMinute": { - "type": "integer", - "format": "int64" + "from": { + "type": "string", + "format": "date-time" }, - "StartMinute": { - "type": "integer", - "format": "int64" + "to": { + "type": "string", + "format": "date-time" } } }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { @@ -6290,7 +6450,6 @@ } }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -6347,7 +6506,6 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" @@ -6355,7 +6513,6 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -6405,7 +6562,6 @@ "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" @@ -6413,7 +6569,6 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { - "description": "Integration integration", "type": "object", "required": [ "name", @@ -6558,6 +6713,7 @@ } }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -6711,6 +6867,15 @@ } }, "responses": { + "GettableHistoricUserConfigs": { + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableHistoricUserConfig" + } + } + }, "receiversResponse": { "description": "", "schema": { diff --git a/pkg/services/ngalert/models/alertmanager.go b/pkg/services/ngalert/models/alertmanager.go index 4321e723a25..985ca281624 100644 --- a/pkg/services/ngalert/models/alertmanager.go +++ b/pkg/services/ngalert/models/alertmanager.go @@ -16,6 +16,7 @@ type AlertConfiguration struct { // HistoricAlertConfiguration represents a previously used alerting configuration. type HistoricAlertConfiguration struct { + ID int64 `xorm:"pk autoincr 'id'"` AlertConfiguration `xorm:"extends"` // LastApplied a timestamp indicating the most recent time at which the configuration was applied to an Alertmanager, or 0 otherwise. @@ -44,13 +45,8 @@ type MarkConfigurationAsAppliedCmd struct { ConfigurationHash string } -// GetAppliedConfigurationsQuery is the query for getting configurations that have been previously applied with no errors. -type GetAppliedConfigurationsQuery struct { - OrgID int64 -} - func HistoricConfigFromAlertConfig(config AlertConfiguration) HistoricAlertConfiguration { - // Reset the Id so it can be generated by the DB. + // Reset the ID so it can be generated by the DB. config.ID = 0 return HistoricAlertConfiguration{ AlertConfiguration: config, diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index bced978b39d..09c02081b54 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "time" + "github.com/go-openapi/strfmt" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -37,18 +39,51 @@ func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Contex if err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to get latest configuration: %w", err) } - cfg, err := Load([]byte(amConfig.AlertmanagerConfiguration)) + + return moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration) +} + +// GetAppliedAlertmanagerConfigurations returns the last n configurations marked as applied for a given org. +func (moa *MultiOrgAlertmanager) GetAppliedAlertmanagerConfigurations(ctx context.Context, org int64, limit int) ([]*definitions.GettableHistoricUserConfig, error) { + configs, err := moa.configStore.GetAppliedConfigurations(ctx, org, limit) + if err != nil { + return []*definitions.GettableHistoricUserConfig{}, fmt.Errorf("failed to get applied configurations: %w", err) + } + + gettableHistoricConfigs := make([]*definitions.GettableHistoricUserConfig, 0, len(configs)) + for _, config := range configs { + appliedAt := strfmt.DateTime(time.Unix(config.LastApplied, 0).UTC()) + gettableConfig, err := moa.gettableUserConfigFromAMConfigString(ctx, org, config.AlertmanagerConfiguration) + if err != nil { + // If there are invalid records, skip them and return the valid ones. + moa.logger.Warn("Invalid configuration found in alert configuration history table", "id", config.ID, "orgID", org) + continue + } + + gettableHistoricConfig := definitions.GettableHistoricUserConfig{ + ID: config.ID, + TemplateFiles: gettableConfig.TemplateFiles, + TemplateFileProvenances: gettableConfig.TemplateFileProvenances, + AlertmanagerConfig: gettableConfig.AlertmanagerConfig, + LastApplied: &appliedAt, + } + gettableHistoricConfigs = append(gettableHistoricConfigs, &gettableHistoricConfig) + } + + return gettableHistoricConfigs, nil +} + +func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx context.Context, orgID int64, config string) (definitions.GettableUserConfig, error) { + cfg, err := Load([]byte(config)) if err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to unmarshal alertmanager configuration: %w", err) } - result := definitions.GettableUserConfig{ TemplateFiles: cfg.TemplateFiles, AlertmanagerConfig: definitions.GettableApiAlertingConfig{ Config: cfg.AlertmanagerConfig.Config, }, } - for _, recv := range cfg.AlertmanagerConfig.Receivers { receivers := make([]*definitions.GettableGrafanaReceiver, 0, len(recv.PostableGrafanaReceivers.GrafanaManagedReceivers)) for _, pr := range recv.PostableGrafanaReceivers.GrafanaManagedReceivers { @@ -82,7 +117,7 @@ func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Contex result.AlertmanagerConfig.Receivers = append(result.AlertmanagerConfig.Receivers, &gettableApiReceiver) } - result, err = moa.mergeProvenance(ctx, result, org) + result, err = moa.mergeProvenance(ctx, result, orgID) if err != nil { return definitions.GettableUserConfig{}, err } diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go index 2f61aeccf4e..a9153587a88 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go @@ -63,8 +63,8 @@ grafana_alerting_discovered_configurations 3 // Configurations should be marked as successfully applied. for _, org := range orgStore.orgs { - configs, ok := configStore.appliedConfigs[org] - require.True(t, ok) + configs, err := configStore.GetAppliedConfigurations(ctx, org, 10) + require.NoError(t, err) require.Len(t, configs, 1) } } @@ -184,8 +184,9 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) // No successfully applied configurations should be found at first. { for _, org := range orgs { - _, ok := configStore.appliedConfigs[org] - require.False(t, ok) + configs, err := configStore.GetAppliedConfigurations(ctx, org, 10) + require.NoError(t, err) + require.Len(t, configs, 0) } } @@ -199,11 +200,11 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) // Configurations should be marked as successfully applied for all orgs except for org 2. for _, org := range orgs { - configs, ok := configStore.appliedConfigs[org] + configs, err := configStore.GetAppliedConfigurations(ctx, org, 10) + require.NoError(t, err) if org == orgWithBadConfig { - require.False(t, ok) + require.Len(t, configs, 0) } else { - require.True(t, ok) require.Len(t, configs, 1) } } @@ -219,11 +220,11 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) // The configuration should still be marked as successfully applied for all orgs except for org 2. for _, org := range orgs { - configs, ok := configStore.appliedConfigs[org] + configs, err := configStore.GetAppliedConfigurations(ctx, org, 10) + require.NoError(t, err) if org == orgWithBadConfig { - require.False(t, ok) + require.Len(t, configs, 0) } else { - require.True(t, ok) require.Len(t, configs, 1) } } @@ -240,9 +241,9 @@ func TestMultiOrgAlertmanager_SyncAlertmanagersForOrgsWithFailures(t *testing.T) // All configurations should be marked as successfully applied. for _, org := range orgs { - configs, ok := configStore.appliedConfigs[org] - require.True(t, ok) - require.Len(t, configs, 1) + configs, err := configStore.GetAppliedConfigurations(ctx, org, 10) + require.NoError(t, err) + require.NotEqual(t, 0, len(configs)) } } } diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 9f0bde94178..22e1cef7c52 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -17,8 +18,8 @@ import ( type fakeConfigStore struct { configs map[int64]*models.AlertConfiguration - // appliedConfigs stores configs by orgID and config hash. - appliedConfigs map[int64]map[string]*models.AlertConfiguration + // historicConfigs stores configs by orgID. + historicConfigs map[int64][]*models.HistoricAlertConfiguration } // Saves the image or returns an error. @@ -37,9 +38,15 @@ func (f *fakeConfigStore) GetImages(ctx context.Context, tokens []string) ([]mod func NewFakeConfigStore(t *testing.T, configs map[int64]*models.AlertConfiguration) *fakeConfigStore { t.Helper() + historicConfigs := make(map[int64][]*models.HistoricAlertConfiguration) + for org, config := range configs { + historicConfig := models.HistoricConfigFromAlertConfig(*config) + historicConfigs[org] = append(historicConfigs[org], &historicConfig) + } + return &fakeConfigStore{ - configs: configs, - appliedConfigs: make(map[int64]map[string]*models.AlertConfiguration), + configs: configs, + historicConfigs: historicConfigs, } } @@ -73,15 +80,14 @@ func (f *fakeConfigStore) SaveAlertmanagerConfigurationWithCallback(_ context.Co } f.configs[cmd.OrgID] = &cfg - if err := callback(); err != nil { - return err + historicConfig := models.HistoricConfigFromAlertConfig(cfg) + if cmd.LastApplied != 0 { + historicConfig.LastApplied = time.Now().UTC().Unix() + f.historicConfigs[cmd.OrgID] = append(f.historicConfigs[cmd.OrgID], &historicConfig) } - if cmd.LastApplied != 0 { - if _, ok := f.appliedConfigs[cmd.OrgID]; !ok { - f.appliedConfigs[cmd.OrgID] = make(map[string]*models.AlertConfiguration) - } - f.appliedConfigs[cmd.OrgID][cfg.ConfigurationHash] = &cfg + if err := callback(); err != nil { + return err } return nil @@ -89,30 +95,63 @@ func (f *fakeConfigStore) SaveAlertmanagerConfigurationWithCallback(_ context.Co func (f *fakeConfigStore) UpdateAlertmanagerConfiguration(_ context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { if config, exists := f.configs[cmd.OrgID]; exists && config.ConfigurationHash == cmd.FetchedConfigurationHash { - f.configs[cmd.OrgID] = &models.AlertConfiguration{ + newConfig := models.AlertConfiguration{ AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, OrgID: cmd.OrgID, ConfigurationHash: fmt.Sprintf("%x", md5.Sum([]byte(cmd.AlertmanagerConfiguration))), ConfigurationVersion: "v1", Default: cmd.Default, } + f.configs[cmd.OrgID] = &newConfig + + historicConfig := models.HistoricConfigFromAlertConfig(newConfig) + f.historicConfigs[cmd.OrgID] = append(f.historicConfigs[cmd.OrgID], &historicConfig) return nil } + return errors.New("config not found or hash not valid") } func (f *fakeConfigStore) MarkConfigurationAsApplied(_ context.Context, cmd *models.MarkConfigurationAsAppliedCmd) error { - for _, config := range f.configs { - if config.ConfigurationHash == cmd.ConfigurationHash && config.OrgID == cmd.OrgID { - if _, ok := f.appliedConfigs[cmd.OrgID]; !ok { - f.appliedConfigs[cmd.OrgID] = make(map[string]*models.AlertConfiguration) + orgConfigs, ok := f.historicConfigs[cmd.OrgID] + if !ok { + return nil + } + + // Iterate backwards to find the latest config first. + for i := len(orgConfigs) - 1; i >= 0; i-- { + for _, config := range orgConfigs { + if config.ConfigurationHash == cmd.ConfigurationHash { + config.LastApplied = time.Now().UTC().Unix() + return nil } - f.appliedConfigs[cmd.OrgID][cmd.ConfigurationHash] = config - return nil } } - return errors.New("config not found") + return nil +} + +func (f *fakeConfigStore) GetAppliedConfigurations(_ context.Context, orgID int64, limit int) ([]*models.HistoricAlertConfiguration, error) { + configsByOrg, ok := f.historicConfigs[orgID] + if !ok { + return []*models.HistoricAlertConfiguration{}, nil + } + + // Iterate backwards to get the latest applied configs. + var configs []*models.HistoricAlertConfiguration + start := len(configsByOrg) - 1 + end := start - limit + if end < 0 { + end = 0 + } + + for i := start; i >= end; i-- { + if configsByOrg[i].LastApplied > 0 { + configs = append(configs, configsByOrg[i]) + } + } + + return configs, nil } type FakeOrgStore struct { diff --git a/pkg/services/ngalert/store/alertmanager.go b/pkg/services/ngalert/store/alertmanager.go index 86871bf9fde..99552442ee9 100644 --- a/pkg/services/ngalert/store/alertmanager.go +++ b/pkg/services/ngalert/store/alertmanager.go @@ -163,22 +163,30 @@ func (st *DBstore) MarkConfigurationAsApplied(ctx context.Context, cmd *models.M } // GetAppliedConfigurations returns all configurations that have been marked as applied, ordered newest -> oldest by id. -func (st *DBstore) GetAppliedConfigurations(ctx context.Context, query *models.GetAppliedConfigurationsQuery) (result []*models.HistoricAlertConfiguration, err error) { - err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { +func (st *DBstore) GetAppliedConfigurations(ctx context.Context, orgID int64, limit int) ([]*models.HistoricAlertConfiguration, error) { + if limit < 1 || limit > ConfigRecordsLimit { + limit = ConfigRecordsLimit + } + + var configs []*models.HistoricAlertConfiguration + if err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { cfgs := []*models.HistoricAlertConfiguration{} err := sess.Table("alert_configuration_history"). Desc("id"). - Where("org_id = ? AND last_applied != 0", query.OrgID). + Where("org_id = ? AND last_applied != 0", orgID). + Limit(limit). Find(&cfgs) - if err != nil { return err } - result = cfgs + configs = cfgs return nil - }) - return result, err + }); err != nil { + return []*models.HistoricAlertConfiguration{}, err + } + + return configs, nil } func (st *DBstore) deleteOldConfigurations(ctx context.Context, orgID int64, limit int) (int64, error) { diff --git a/pkg/services/ngalert/store/alertmanager_test.go b/pkg/services/ngalert/store/alertmanager_test.go index 741366e77bc..150adc9104d 100644 --- a/pkg/services/ngalert/store/alertmanager_test.go +++ b/pkg/services/ngalert/store/alertmanager_test.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "testing" + "time" "github.com/stretchr/testify/require" @@ -296,6 +297,7 @@ func TestIntegrationMarkConfigurationAsApplied(t *testing.T) { t.Run("marking an existent config should succeed", func(tt *testing.T) { const orgID = 1 + limit := 10 ctx := context.Background() config, _ := setupConfig(t, "test", store) @@ -308,10 +310,7 @@ func TestIntegrationMarkConfigurationAsApplied(t *testing.T) { require.NoError(tt, err) // Config should be saved but not marked as applied yet. - appliedCfgsQuery := models.GetAppliedConfigurationsQuery{ - OrgID: orgID, - } - configs, err := store.GetAppliedConfigurations(ctx, &appliedCfgsQuery) + configs, err := store.GetAppliedConfigurations(ctx, orgID, limit) require.NoError(tt, err) require.Len(tt, configs, 0) @@ -329,15 +328,103 @@ func TestIntegrationMarkConfigurationAsApplied(t *testing.T) { require.NoError(tt, err) // Config should now be saved and marked as successfully applied. - appliedCfgsQuery = models.GetAppliedConfigurationsQuery{ - OrgID: orgID, - } - configs, err = store.GetAppliedConfigurations(ctx, &appliedCfgsQuery) + configs, err = store.GetAppliedConfigurations(ctx, orgID, limit) require.NoError(tt, err) require.Len(tt, configs, 1) }) } +func TestIntegrationGetAppliedConfigurations(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + sqlStore := db.InitTestDB(t) + store := &DBstore{ + SQLStore: sqlStore, + Logger: log.NewNopLogger(), + } + + t.Run("no configurations = empty slice", func(tt *testing.T) { + configs, err := store.GetAppliedConfigurations(context.Background(), 10, 10) + require.NoError(tt, err) + require.NotNil(tt, configs) + require.Len(tt, configs, 0) + }) + + t.Run("saved configurations marked as applied should be returned", func(tt *testing.T) { + ctx := context.Background() + var org int64 = 1 + limit := 10 + unmarkedConfig, _ := setupConfig(t, "unmarked", store) + + // Save four configurations for the same org. + for i := 0; i < 4; i++ { + config, _ := setupConfig(t, fmt.Sprintf("test-%d", i+1), store) + cmd := &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: config, + ConfigurationVersion: "v1", + Default: false, + OrgID: org, + LastApplied: time.Now().UTC().Unix(), + } + + // Don't mark the third config, that way we have 2 marked, 1 unmarked, 1 marked. + if i == 2 { + cmd.LastApplied = 0 + cmd.AlertmanagerConfiguration = unmarkedConfig + } + + err := store.SaveAlertmanagerConfiguration(ctx, cmd) + require.NoError(tt, err) + } + + // Save some configs for other orgs. + for i := 0; i < 4; i++ { + config, _ := setupConfig(t, fmt.Sprintf("test-%d", i+1), store) + cmd := &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: config, + ConfigurationVersion: "v1", + Default: false, + OrgID: int64(i) + org + 1, // This way we avoid saving more configs for the same org. + LastApplied: time.Now().UTC().Unix(), + } + + err := store.SaveAlertmanagerConfiguration(ctx, cmd) + require.NoError(tt, err) + } + + configs, err := store.GetAppliedConfigurations(ctx, org, limit) + require.NoError(tt, err) + require.Len(tt, configs, 3) + + var lastID int64 + for _, config := range configs { + // Check that the returned configurations are the ones that we're expecting. + require.NotEqual(tt, config.AlertConfiguration.AlertmanagerConfiguration, unmarkedConfig) + + // Configs should only belong to the queried org. + require.Equal(tt, org, config.OrgID) + + // LastApplied must not be zero. + require.NotZero(tt, config.LastApplied) + + // Configs should be returned in descending order (id). + if lastID != 0 { + require.LessOrEqual(tt, config.AlertConfiguration.ID, lastID) + } + lastID = config.AlertConfiguration.ID + } + + // The limit should be considered by the store. + // The only record returned should be the latest one (highest id). + highestID := configs[0].ID + configs, err = store.GetAppliedConfigurations(ctx, org, 1) + require.NoError(tt, err) + require.Len(tt, configs, 1) + require.Equal(tt, configs[0].ID, highestID) + }) +} + func setupConfig(t *testing.T, config string, store *DBstore) (string, string) { t.Helper() return setupConfigInOrg(t, config, 1, store) diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index 2854600c41b..f91ac4788f7 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -28,6 +28,7 @@ type AlertingStore interface { SaveAlertmanagerConfigurationWithCallback(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd, callback SaveCallback) error UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error MarkConfigurationAsApplied(ctx context.Context, cmd *models.MarkConfigurationAsAppliedCmd) error + GetAppliedConfigurations(ctx context.Context, orgID int64, limit int) ([]*models.HistoricAlertConfiguration, error) } // DBstore stores the alert definitions and instances in the database.