diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 256f3f5f8d61..1b9e58893864 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -26,6 +26,7 @@ var timeNow = time.Now type Alertmanager interface { // Configuration SaveAndApplyConfig(config *apimodels.PostableUserConfig) error + GetStatus() apimodels.GettableStatus // Silences CreateSilence(ps *apimodels.PostableSilence) (string, error) diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index c51fdea4c01d..9e478df00ecb 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -20,6 +20,10 @@ type AlertmanagerSrv struct { log log.Logger } +func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Response { + return response.JSON(http.StatusOK, srv.am.GetStatus()) +} + func (srv AlertmanagerSrv) RouteCreateSilence(c *models.ReqContext, postableSilence apimodels.PostableSilence) response.Response { silenceID, err := srv.am.CreateSilence(&postableSilence) if err != nil { diff --git a/pkg/services/ngalert/api/forked_am.go b/pkg/services/ngalert/api/forked_am.go index 0256e642a6a8..1c2aa4ddb9d1 100644 --- a/pkg/services/ngalert/api/forked_am.go +++ b/pkg/services/ngalert/api/forked_am.go @@ -39,6 +39,15 @@ func (am *ForkedAMSvc) getService(ctx *models.ReqContext) (AlertmanagerApiServic } } +func (am *ForkedAMSvc) RouteGetAMStatus(ctx *models.ReqContext) response.Response { + s, err := am.getService(ctx) + if err != nil { + return response.Error(400, err.Error(), nil) + } + + return s.RouteGetAMStatus(ctx) +} + func (am *ForkedAMSvc) RouteCreateSilence(ctx *models.ReqContext, body apimodels.PostableSilence) response.Response { s, err := am.getService(ctx) if err != nil { diff --git a/pkg/services/ngalert/api/generated_base_api_alertmanager.go b/pkg/services/ngalert/api/generated_base_api_alertmanager.go index 02d3c1ad83cf..e71c1ab8a7cf 100644 --- a/pkg/services/ngalert/api/generated_base_api_alertmanager.go +++ b/pkg/services/ngalert/api/generated_base_api_alertmanager.go @@ -23,6 +23,7 @@ type AlertmanagerApiService interface { RouteDeleteSilence(*models.ReqContext) response.Response RouteGetAMAlertGroups(*models.ReqContext) response.Response RouteGetAMAlerts(*models.ReqContext) response.Response + RouteGetAMStatus(*models.ReqContext) response.Response RouteGetAlertingConfig(*models.ReqContext) response.Response RouteGetSilence(*models.ReqContext) response.Response RouteGetSilences(*models.ReqContext) response.Response @@ -37,6 +38,7 @@ func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApiService) { group.Delete(toMacaronPath("/api/alertmanager/{Recipient}/api/v2/silence/{SilenceId}"), routing.Wrap(srv.RouteDeleteSilence)) group.Get(toMacaronPath("/api/alertmanager/{Recipient}/api/v2/alerts/groups"), routing.Wrap(srv.RouteGetAMAlertGroups)) group.Get(toMacaronPath("/api/alertmanager/{Recipient}/api/v2/alerts"), routing.Wrap(srv.RouteGetAMAlerts)) + group.Get(toMacaronPath("/api/alertmanager/{Recipient}/api/v2/status"), routing.Wrap(srv.RouteGetAMStatus)) group.Get(toMacaronPath("/api/alertmanager/{Recipient}/config/api/v1/alerts"), routing.Wrap(srv.RouteGetAlertingConfig)) group.Get(toMacaronPath("/api/alertmanager/{Recipient}/api/v2/silence/{SilenceId}"), routing.Wrap(srv.RouteGetSilence)) group.Get(toMacaronPath("/api/alertmanager/{Recipient}/api/v2/silences"), routing.Wrap(srv.RouteGetSilences)) diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index 136328c52d47..de92ee187b97 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -16,6 +16,7 @@ import ( const ( amSilencesPath = "/alertmanager/api/v2/silences" amSilencePath = "/alertmanager/api/v2/silence/%s" + amStatusPath = "/alertmanager/api/v2/status" amAlertGroupsPath = "/alertmanager/api/v2/alerts/groups" amAlertsPath = "/alertmanager/api/v2/alerts" amConfigPath = "/api/v1/alerts" @@ -33,6 +34,20 @@ func NewLotexAM(proxy *AlertingProxy, log log.Logger) *LotexAM { } } +func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response { + return am.withReq( + ctx, + http.MethodGet, + withPath( + *ctx.Req.URL, + amStatusPath, + ), + nil, + jsonExtractor(&apimodels.GettableStatus{}), + nil, + ) +} + func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimodels.PostableSilence) response.Response { blob, err := json.Marshal(silenceBody) if err != nil { diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 02a68efc9ed5..e64cca11240a 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" + "github.com/go-openapi/strfmt" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" amv2 "github.com/prometheus/alertmanager/api/v2/models" @@ -35,6 +37,14 @@ import ( // 200: Ack // 400: ValidationError +// swagger:route GET /api/alertmanager/{Recipient}/api/v2/status alertmanager RouteGetAMStatus +// +// get alertmanager status and configuration +// +// Responses: +// 200: GettableStatus +// 400: ValidationError + // swagger:route GET /api/alertmanager/{Recipient}/api/v2/alerts alertmanager RouteGetAMAlerts // // get alertmanager alerts @@ -109,6 +119,47 @@ type GetSilencesParams struct { Filter []string `json:"filter"` } +// swagger:model +type GettableStatus struct { + // cluster + // Required: true + Cluster *amv2.ClusterStatus `json:"cluster"` + + // config + // Required: true + Config *PostableApiAlertingConfig `json:"config"` + + // uptime + // Required: true + // Format: date-time + Uptime *strfmt.DateTime `json:"uptime"` + + // version info + // Required: true + VersionInfo *amv2.VersionInfo `json:"versionInfo"` +} + +func NewGettableStatus(cfg *PostableApiAlertingConfig) *GettableStatus { + // In Grafana, the only field we support is Config. + cs := amv2.ClusterStatusStatusDisabled + na := "N/A" + return &GettableStatus{ + Cluster: &amv2.ClusterStatus{ + Status: &cs, + Peers: []*amv2.PeerStatus{}, + }, + VersionInfo: &amv2.VersionInfo{ + Branch: &na, + BuildDate: &na, + BuildUser: &na, + GoVersion: &na, + Revision: &na, + Version: &na, + }, + Config: cfg, + } +} + // swagger:model type PostableSilence = amv2.PostableSilence @@ -178,7 +229,7 @@ type BodyAlertingConfig struct { } // alertmanager routes -// swagger:parameters RoutePostAlertingConfig RouteGetAlertingConfig RouteDeleteAlertingConfig RouteGetAMAlerts RoutePostAMAlerts RouteGetAMAlertGroups RouteGetSilences RouteCreateSilence RouteGetSilence RouteDeleteSilence RoutePostAlertingConfig +// swagger:parameters RoutePostAlertingConfig RouteGetAlertingConfig RouteDeleteAlertingConfig RouteGetAMStatus RouteGetAMAlerts RoutePostAMAlerts RouteGetAMAlertGroups RouteGetSilences RouteCreateSilence RouteGetSilence RouteDeleteSilence RoutePostAlertingConfig // ruler routes // swagger:parameters RouteGetRulesConfig RoutePostNameRulesConfig RouteGetNamespaceRulesConfig RouteDeleteNamespaceRulesConfig RouteGetRulegGroupConfig RouteDeleteRuleGroupConfig // prom routes diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index e190f340130e..2755ae9bd696 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -828,7 +828,10 @@ "GettableSilence": { "$ref": "#/definitions/gettableSilence" }, - "GettableSilences": {}, + "GettableSilences": { + "$ref": "#/definitions/gettableSilences" + }, + "GettableStatus": {}, "GettableUserConfig": { "properties": { "alertmanager_config": { @@ -1589,7 +1592,6 @@ "x-go-package": "github.com/prometheus/alertmanager/config" }, "Receiver": { - "$ref": "#/definitions/receiver", "properties": { "email_configs": { "items": { @@ -2176,7 +2178,6 @@ "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" }, "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" @@ -2209,9 +2210,9 @@ "$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", - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", @@ -2380,7 +2381,7 @@ "alerts": { "description": "alerts", "items": { - "$ref": "#/definitions/gettableAlert" + "$ref": "#/definitions/GettableAlert" }, "type": "array", "x-go-name": "Alerts" @@ -2404,7 +2405,7 @@ "alertGroups": { "description": "AlertGroups alert groups", "items": { - "$ref": "#/definitions/alertGroup" + "$ref": "#/definitions/AlertGroup" }, "type": "array", "x-go-name": "AlertGroups", @@ -3273,7 +3274,7 @@ "in": "body", "name": "Silence", "schema": { - "$ref": "#/definitions/postableSilence" + "$ref": "#/definitions/PostableSilence" } }, { @@ -3303,6 +3304,32 @@ ] } }, + "/api/alertmanager/{Recipient}/api/v2/status": { + "get": { + "description": "get alertmanager status and configuration", + "operationId": "RouteGetAMStatus", + "parameters": [ + { + "description": "Recipient should be \"grafana\" for requests to be handled by grafana\nand the numeric datasource id for requests to be forwarded to a datasource", + "in": "path", + "name": "Recipient", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "GettableStatus", + "schema": { + "$ref": "#/definitions/GettableStatus" + } + } + }, + "tags": [ + "alertmanager" + ] + } + }, "/api/alertmanager/{Recipient}/config/api/v1/alerts": { "delete": { "description": "deletes the Alerting config for a tenant", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index dc79ed526a7d..f567b2591cb5 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -327,7 +327,7 @@ "name": "Silence", "in": "body", "schema": { - "$ref": "#/definitions/postableSilence" + "$ref": "#/definitions/PostableSilence" } }, { @@ -354,6 +354,32 @@ } } }, + "/api/alertmanager/{Recipient}/api/v2/status": { + "get": { + "description": "get alertmanager status and configuration", + "tags": [ + "alertmanager" + ], + "operationId": "RouteGetAMStatus", + "parameters": [ + { + "type": "string", + "description": "Recipient should be \"grafana\" for requests to be handled by grafana\nand the numeric datasource id for requests to be forwarded to a datasource", + "name": "Recipient", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "GettableStatus", + "schema": { + "$ref": "#/definitions/GettableStatus" + } + } + } + } + }, "/api/alertmanager/{Recipient}/config/api/v1/alerts": { "get": { "description": "gets an Alerting config", @@ -1641,7 +1667,10 @@ "$ref": "#/definitions/gettableSilence" }, "GettableSilences": { - "$ref": "#/definitions/GettableSilences" + "$ref": "#/definitions/gettableSilences" + }, + "GettableStatus": { + "$ref": "#/definitions/GettableStatus" }, "GettableUserConfig": { "type": "object", @@ -2471,7 +2500,7 @@ "x-go-name": "WechatConfigs" } }, - "$ref": "#/definitions/receiver" + "$ref": "#/definitions/Receiver" }, "Regexp": { "description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.", @@ -2993,9 +3022,8 @@ "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" }, "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.", "type": "object", - "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.", "properties": { "ForceQuery": { "type": "boolean" @@ -3028,7 +3056,7 @@ "$ref": "#/definitions/Userinfo" } }, - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", @@ -3204,7 +3232,7 @@ "description": "alerts", "type": "array", "items": { - "$ref": "#/definitions/gettableAlert" + "$ref": "#/definitions/GettableAlert" }, "x-go-name": "Alerts" }, @@ -3222,7 +3250,7 @@ "description": "AlertGroups alert groups", "type": "array", "items": { - "$ref": "#/definitions/alertGroup" + "$ref": "#/definitions/AlertGroup" }, "x-go-name": "AlertGroups", "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" diff --git a/pkg/services/ngalert/notifier/status.go b/pkg/services/ngalert/notifier/status.go new file mode 100644 index 000000000000..8726166dcbe7 --- /dev/null +++ b/pkg/services/ngalert/notifier/status.go @@ -0,0 +1,22 @@ +package notifier + +import ( + "encoding/json" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func (am *Alertmanager) GetStatus() apimodels.GettableStatus { + am.reloadConfigMtx.RLock() + defer am.reloadConfigMtx.RUnlock() + + var amConfig apimodels.PostableApiAlertingConfig + if am.config != nil { + err := json.Unmarshal(am.config, &amConfig) + if err != nil { + // this should never error here, if the configuration is running it should be valid. + am.logger.Error("unable to marshal alertmanager configuration", "err", err) + } + } + return *apimodels.NewGettableStatus(&amConfig) +} diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 184e5c3cb724..e01e4d401155 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -714,7 +714,7 @@ func TestAlertRuleCRUD(t *testing.T) { "annotations": { "annotation1": "val42", "foo": "bar" - }, + }, "expr":"", "for": "30s", "labels": { @@ -799,6 +799,71 @@ func TestAlertRuleCRUD(t *testing.T) { } } +func TestAlertmanagerStatus(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + + // Get the Alertmanager current status. + { + alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/status", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + fmt.Println(string(b)) + require.Equal(t, 200, resp.StatusCode) + require.JSONEq(t, ` +{ + "cluster": { + "peers": [], + "status": "disabled" + }, + "config": { + "route": { + "receiver": "grafana-default-email" + }, + "templates": null, + "receivers": [{ + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [{ + "uid": "", + "name": "email receiver", + "type": "email", + "sendReminder": false, + "disableResolveMessage": false, + "frequency": "", + "isDefault": true, + "settings": { + "addresses": "\u003cexample@email.com\u003e" + }, + "secureSettings": null, + "Result": null + }] + }] + }, + "uptime": null, + "versionInfo": { + "branch": "N/A", + "buildDate": "N/A", + "buildUser": "N/A", + "goVersion": "N/A", + "revision": "N/A", + "version": "N/A" + } +} +`, string(b)) + } +} + // createFolder creates a folder for storing our alerts under. Grafana uses folders as a replacement for alert namespaces to match its permission model. // We use the dashboard command using IsFolder = true to tell it's a folder, it takes the dashboard as the name of the folder. func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folderName string) error {