Alerting: Add contact point provisioning file export (#71692)

* Add contact point provisioning file export apis

* Regenerate api

* docs

* frontend

* add mock to tests

* Fix missing row-level export button on viewer role w/ prov. read

* Address review comments

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Matthew Jacobson 2023-07-20 14:35:56 -04:00 committed by GitHub
parent a7c639f16e
commit 13121d3234
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1154 additions and 193 deletions

View File

@ -137,11 +137,13 @@ deleteRules:
Create or delete contact points in your Grafana instance(s).
1. Create a YAML or JSON configuration file.
1. Create a contact point in Grafana.
1. Use the [Alerting provisioning API]({{< relref "../../../../developers/http_api/alerting_provisioning" >}}) export endpoints to download a provisioning file for your contact point.
1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory.
Example configuration files can be found below.
1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s).
Here is an example of a configuration file for creating contact points.

View File

@ -60,12 +60,13 @@ title: 'Alerting Provisioning HTTP API '
Contact point provisioning is for Grafana-managed alerts only.
| Method | URI | Name | Summary |
| ------ | ----------------------------------------- | --------------------------------------------------------- | --------------------------------- |
| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. |
| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. |
| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. |
| PUT | /api/v1/provisioning/contact-points/{UID} | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. |
| Method | URI | Name | Summary |
| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ |
| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. |
| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. |
| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. |
| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. |
| PUT | /api/v1/provisioning/contact-points/{UID} | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. |
### Notification policies
@ -258,11 +259,11 @@ GET /api/v1/provisioning/alert-rules/{UID}/export
#### Parameters
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| -------- | ------- | -------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| UID | `path` | string | `string` | | ✓ | | Alert rule UID |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | `string` | string | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| UID | `path` | string | `string` | | ✓ | | Alert rule UID |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
#### All responses
@ -337,12 +338,12 @@ GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export
#### Parameters
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| --------- | ------- | -------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| FolderUID | `path` | string | `string` | | ✓ | | |
| Group | `path` | string | `string` | | ✓ | | |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | `string` | string | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| --------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| FolderUID | `path` | string | `string` | | ✓ | | |
| Group | `path` | string | `string` | | ✓ | | |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
#### All responses
@ -397,10 +398,10 @@ GET /api/v1/provisioning/alert-rules/export
#### Parameters
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| -------- | ------- | -------- | ------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | `string` | string | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
#### All responses
@ -453,6 +454,46 @@ Status: OK
[ContactPoints](#contact-points)
### <span id="route-get-contactpoints-export"></span> Export all contact points in provisioning file format. (_RouteGetContactpointsExport_)
```
GET /api/v1/provisioning/contact-points/export
```
#### Parameters
| Name | Source | Type | Go type | Separator | Required | Default | Description |
| -------- | ------- | ------- | -------- | --------- | :------: | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| decrypt | `query` | boolean | `bool` | | | | Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. |
| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. |
| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. |
| name | `query` | string | `string` | | | | Filter by name |
#### All responses
| Code | Status | Description | Has headers | Schema |
| ------------------------------------------ | --------- | ------------------ | :---------: | ---------------------------------------------------- |
| [200](#route-get-contactpoints-export-200) | OK | AlertingFileExport | | [schema](#route-get-contactpoints-export-200-schema) |
| [403](#route-get-contactpoints-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-contactpoints-export-403-schema) |
#### Responses
##### <span id="route-get-contactpoints-export-200"></span> 200 - AlertingFileExport
Status: OK
###### <span id="route-get-contactpoints-export-200-schema"></span> Schema
[AlertingFileExport](#alerting-file-export)
##### <span id="route-get-contactpoints-export-403"></span> 403 - PermissionDenied
Status: Forbidden
###### <span id="route-get-contactpoints-export-403-schema"></span> Schema
[PermissionDenied](#permission-denied)
### <span id="route-get-mute-timing"></span> Get a mute timing. (_RouteGetMuteTiming_)
```
@ -1092,9 +1133,10 @@ Status: Accepted
| annotations | map of string | `map[string]string` | | | | |
| condition | string | `string` | | | | |
| dasboardUid | string | `string` | | | | |
| data | [][alertqueryexport](#alert-query-export) | `[]*AlertQueryExport` | | | | |
| data | [][AlertQueryExport](#alert-query-export) | `[]*AlertQueryExport` | | | | |
| execErrState | string | `string` | | | | |
| for | [Duration](#duration) | `Duration` | | | | |
| isPaused | boolean | `bool` | | | | |
| labels | map of string | `map[string]string` | | | | |
| noDataState | string | `string` | | | | |
| panelId | int64 (formatted integer) | `int64` | | | | |
@ -1113,7 +1155,7 @@ Status: Accepted
| --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- |
| folderUid | string | `string` | | | | |
| interval | int64 (formatted integer) | `int64` | | | | |
| rules | [][provisionedalertrule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | |
| rules | [][ProvisionedAlertRule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | |
| title | string | `string` | | | | |
{{% /responsive-table %}}
@ -1130,7 +1172,7 @@ Status: Accepted
| interval | [Duration](#duration) | `Duration` | | | | |
| name | string | `string` | | | | |
| orgId | int64 (formatted integer) | `int64` | | | | |
| rules | [][alertruleexport](#alert-rule-export) | `[]*AlertRuleExport` | | | | |
| rules | [][AlertRuleExport](#alert-rule-export) | `[]*AlertRuleExport` | | | | |
{{% /responsive-table %}}
@ -1140,16 +1182,27 @@ Status: Accepted
{{% responsive-table %}}
| Name | Type | Go type | Required | Default | Description | Example |
| ---------- | -------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- |
| apiVersion | int64 (formatted integer) | `int64` | | | | |
| groups | [][alertrulegroupexport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | |
| Name | Type | Go type | Required | Default | Description | Example |
| ------------- | -------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- |
| apiVersion | int64 (formatted integer) | `int64` | | | | |
| contactPoints | [][ContactPointExport](#contact-point-export) | `[]*ContactPointExport` | | | | |
| groups | [][AlertRuleGroupExport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | |
{{% /responsive-table %}}
### <span id="contact-point-export"></span> ContactPointExport
**Properties**
| Name | Type | Go type | Required | Default | Description | Example |
| --------- | ------------------------------------ | ------------------- | :------: | ------- | ----------- | ------- |
| name | string | `string` | | | | |
| orgId | int64 (formatted integer) | `int64` | | | | |
| receivers | [][ReceiverExport](#receiver-export) | `[]*ReceiverExport` | | | | |
### <span id="contact-points"></span> ContactPoints
[][embeddedcontactpoint](#embedded-contact-point)
[][EmbeddedContactPoint](#embedded-contact-point)
### <span id="duration"></span> Duration
@ -1213,7 +1266,7 @@ Status: Accepted
> provides a Matches method to match a LabelSet against all Matchers in the
> slice. Note that some users of Matchers might require it to be sorted.
[][matcher](#matcher)
[][Matcher](#matcher)
### <span id="mute-time-interval"></span> MuteTimeInterval
@ -1224,13 +1277,13 @@ Status: Accepted
| Name | Type | Go type | Required | Default | Description | Example |
| -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- |
| name | string | `string` | | | | |
| time_intervals | [][timeinterval](#time-interval) | `[]*TimeInterval` | | | | |
| time_intervals | [][TimeInterval](#time-interval) | `[]*TimeInterval` | | | | |
{{% /responsive-table %}}
### <span id="mute-timings"></span> MuteTimings
[][mutetimeinterval](#mute-time-interval)
[][MuteTimeInterval](#mute-time-interval)
### <span id="notification-template"></span> NotificationTemplate
@ -1260,7 +1313,7 @@ Status: Accepted
### <span id="notification-templates"></span> NotificationTemplates
[][notificationtemplate](#notification-template)
[][NotificationTemplate](#notification-template)
### <span id="object-matchers"></span> ObjectMatchers
@ -1268,6 +1321,10 @@ Status: Accepted
#### Inlined models
### <span id="permission-denied"></span> PermissionDenied
[interface{}](#interface)
### <span id="provenance"></span> Provenance
| Name | Type | Go type | Default | Description | Example |
@ -1284,11 +1341,12 @@ Status: Accepted
| ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` |
| condition | string | `string` | ✓ | | | `A` |
| data | [][alertquery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` |
| data | [][AlertQuery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` |
| execErrState | string | `string` | ✓ | | | |
| folderUID | string | `string` | ✓ | | | `project_x` |
| for | [Duration](#duration) | `Duration` | ✓ | | | |
| id | int64 (formatted integer) | `int64` | | | | |
| isPaused | boolean | `bool` | | | | `false` |
| labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` |
| noDataState | string | `string` | ✓ | | | |
| orgID | int64 (formatted integer) | `int64` | ✓ | | | |
@ -1302,7 +1360,18 @@ Status: Accepted
### <span id="provisioned-alert-rules"></span> ProvisionedAlertRules
[][provisionedalertrule](#provisioned-alert-rule)
[][ProvisionedAlertRule](#provisioned-alert-rule)
### <span id="receiver-export"></span> ReceiverExport
**Properties**
| Name | Type | Go type | Required | Default | Description | Example |
| --------------------- | ------------- | -------- | :------: | ------- | ----------- | ------- |
| disableResolveMessage | boolean | `bool` | | | | |
| settings | [JSON](#json) | `JSON` | | | | |
| type | string | `string` | | | | |
| uid | string | `string` | | | | |
### <span id="regexp"></span> Regexp
@ -1350,7 +1419,7 @@ Status: Accepted
| provenance | [Provenance](#provenance) | `Provenance` | | | | |
| receiver | string | `string` | | | | |
| repeat_interval | string | `string` | | | | |
| routes | [][route](#route) | `[]*Route` | | | | |
| routes | [][Route](#route) | `[]*Route` | | | | |
{{% /responsive-table %}}
@ -1368,7 +1437,7 @@ Status: Accepted
| days_of_month | []string | `[]string` | | | | |
| location | string | `string` | | | | |
| months | []string | `[]string` | | | | |
| times | [][timerange](#time-range) | `[]*TimeRange` | | | | |
| times | [][TimeRange](#time-range) | `[]*TimeRange` | | | | |
| weekdays | []string | `[]string` | | | | |
| years | []string | `[]string` | | | | |

View File

@ -14,6 +14,7 @@ import (
alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
)
@ -29,7 +30,7 @@ type ProvisioningSrv struct {
}
type ContactPointService interface {
GetContactPoints(ctx context.Context, q provisioning.ContactPointQuery) ([]definitions.EmbeddedContactPoint, error)
GetContactPoints(ctx context.Context, q provisioning.ContactPointQuery, user *user.SignedInUser) ([]definitions.EmbeddedContactPoint, error)
CreateContactPoint(ctx context.Context, orgID int64, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) (definitions.EmbeddedContactPoint, error)
UpdateContactPoint(ctx context.Context, orgID int64, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) error
DeleteContactPoint(ctx context.Context, orgID int64, uid string) error
@ -108,13 +109,38 @@ func (srv *ProvisioningSrv) RouteGetContactPoints(c *contextmodel.ReqContext) re
Name: c.Query("name"),
OrgID: c.OrgID,
}
cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), q)
cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), q, nil)
if err != nil {
if errors.Is(err, provisioning.ErrPermissionDenied) {
return ErrResp(http.StatusForbidden, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusOK, cps)
}
func (srv *ProvisioningSrv) RouteGetContactPointsExport(c *contextmodel.ReqContext) response.Response {
q := provisioning.ContactPointQuery{
Name: c.Query("name"),
OrgID: c.OrgID,
Decrypt: c.QueryBoolWithDefault("decrypt", false),
}
cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), q, c.SignedInUser)
if err != nil {
if errors.Is(err, provisioning.ErrPermissionDenied) {
return ErrResp(http.StatusForbidden, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
}
e, err := AlertingFileExportFromEmbeddedContactPoints(c.OrgID, cps)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export")
}
return exportResponse(c, e)
}
func (srv *ProvisioningSrv) RoutePostContactPoint(c *contextmodel.ReqContext, cp definitions.EmbeddedContactPoint) response.Response {
provenance := determineProvenance(c)
contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgID, cp, alerting_models.Provenance(provenance))
@ -433,7 +459,7 @@ func determineProvenance(ctx *contextmodel.ReqContext) definitions.Provenance {
return definitions.Provenance(alerting_models.ProvenanceAPI)
}
func exportResponse(c *contextmodel.ReqContext, body any) response.Response {
func extractExportRequest(c *contextmodel.ReqContext) definitions.ExportQueryParams {
var format = "yaml"
acceptHeader := c.Req.Header.Get("Accept")
@ -450,17 +476,26 @@ func exportResponse(c *contextmodel.ReqContext, body any) response.Response {
format = queryFormat
}
download := c.QueryBoolWithDefault("download", false)
if download {
params := definitions.ExportQueryParams{
Format: format,
Download: c.QueryBoolWithDefault("download", false),
}
return params
}
func exportResponse(c *contextmodel.ReqContext, body definitions.AlertingFileExport) response.Response {
params := extractExportRequest(c)
if params.Download {
r := response.JSONDownload
if format == "yaml" {
if params.Format == "yaml" {
r = response.YAMLDownload
}
return r(http.StatusOK, body, fmt.Sprintf("export.%s", format))
return r(http.StatusOK, body, fmt.Sprintf("export.%s", params.Format))
}
r := response.JSON
if format == "yaml" {
if params.Format == "yaml" {
r = response.YAML
}
return r(http.StatusOK, body)

View File

@ -23,8 +23,10 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"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/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/secrets"
secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/user"
@ -316,7 +318,7 @@ func TestProvisioningApi(t *testing.T) {
})
t.Run("have reached the rule quota, POST returns 403", func(t *testing.T) {
env := createTestEnv(t)
env := createTestEnv(t, testConfig)
quotas := provisioning.MockQuotaChecker{}
quotas.EXPECT().LimitExceeded()
env.quotas = &quotas
@ -813,6 +815,230 @@ func TestProvisioningApi(t *testing.T) {
})
}
func TestProvisioningApiContactPointExport(t *testing.T) {
t.Run("contact point export", func(t *testing.T) {
t.Run("are present, GET returns 200", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
response := sut.RouteGetContactPointsExport(&rc)
require.Equal(t, 200, response.Status())
})
t.Run("accept header contains yaml, GET returns text yaml", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/yaml")
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type"))
})
t.Run("accept header contains json, GET returns json", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/json")
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type"))
})
t.Run("accept header contains json and yaml, GET returns json", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/json, application/yaml")
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type"))
})
t.Run("query param download=true, GET returns content disposition attachment", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.Context.Req.Form.Set("download", "true")
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Contains(t, rc.Context.Resp.Header().Get("Content-Disposition"), "attachment")
})
t.Run("query param download=false, GET returns empty content disposition", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.Context.Req.Form.Set("download", "false")
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition"))
})
t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition"))
})
t.Run("decrypt true without admin returns 403", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.Context.Req.Form.Set("decrypt", "true")
response := sut.RouteGetContactPointsExport(&rc)
require.Equal(t, 403, response.Status())
})
t.Run("decrypt true with admin returns 200", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
rc.SignedInUser.OrgRole = org.RoleAdmin
rc.Context.Req.Form.Set("decrypt", "true")
response := sut.RouteGetContactPointsExport(&rc)
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
})
t.Run("json body content is as expected", func(t *testing.T) {
expectedRedactedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"[REDACTED]","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"[REDACTED]"},"disableResolveMessage":true}]}]}`
t.Run("decrypt false", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/json")
rc.Context.Req.Form.Set("decrypt", "false")
response := sut.RouteGetContactPointsExport(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, expectedRedactedResponse, string(response.Body()))
})
t.Run("decrypt missing", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/json")
response := sut.RouteGetContactPointsExport(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, expectedRedactedResponse, string(response.Body()))
})
t.Run("decrypt true", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.SignedInUser.OrgRole = org.RoleAdmin
rc.Context.Req.Header.Add("Accept", "application/json")
rc.Context.Req.Form.Set("decrypt", "true")
response := sut.RouteGetContactPointsExport(&rc)
expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"testpass","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"some key","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"some secure slack webhook"},"disableResolveMessage":true}]}]}`
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
t.Run("name filters response", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/json")
rc.Context.Req.Form.Set("name", "multiple integrations")
response := sut.RouteGetContactPointsExport(&rc)
expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]}]}`
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
})
t.Run("yaml body content is as expected", func(t *testing.T) {
expectedRedactedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: <example@email.com>\n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: '[REDACTED]'\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: '[REDACTED]'\n disableResolveMessage: true\n"
t.Run("decrypt false", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/yaml")
rc.Context.Req.Form.Set("decrypt", "false")
response := sut.RouteGetContactPointsExport(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, expectedRedactedResponse, string(response.Body()))
})
t.Run("decrypt missing", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/yaml")
response := sut.RouteGetContactPointsExport(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, expectedRedactedResponse, string(response.Body()))
})
t.Run("decrypt true", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.SignedInUser.OrgRole = org.RoleAdmin
rc.Context.Req.Header.Add("Accept", "application/yaml")
rc.Context.Req.Form.Set("decrypt", "true")
response := sut.RouteGetContactPointsExport(&rc)
expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: <example@email.com>\n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: testpass\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: some key\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: some secure slack webhook\n disableResolveMessage: true\n"
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
t.Run("name filters response", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
rc := createTestRequestCtx()
rc.Context.Req.Header.Add("Accept", "application/yaml")
rc.Context.Req.Form.Set("name", "multiple integrations")
response := sut.RouteGetContactPointsExport(&rc)
expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n"
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
})
})
}
// testEnvironment binds together common dependencies for testing alerting APIs.
type testEnvironment struct {
secrets secrets.Service
@ -825,15 +1051,27 @@ type testEnvironment struct {
prov provisioning.ProvisioningStore
}
func createTestEnv(t *testing.T) testEnvironment {
func createTestEnv(t *testing.T, testConfig string) testEnvironment {
t.Helper()
secrets := secrets_fakes.NewFakeSecretsService()
secretsService := secrets_fakes.NewFakeSecretsService()
// Encrypt secure settings.
c, err := notifier.Load([]byte(testConfig))
require.NoError(t, err)
err = c.EncryptConfig(func(ctx context.Context, payload []byte) ([]byte, error) {
return secretsService.Encrypt(ctx, payload, secrets.WithoutScope())
})
require.NoError(t, err)
raw, err := json.Marshal(c)
require.NoError(t, err)
log := log.NewNopLogger()
configs := &provisioning.MockAMConfigStore{}
configs.EXPECT().
GetsConfig(models.AlertConfiguration{
AlertmanagerConfiguration: testConfig,
AlertmanagerConfiguration: string(raw),
})
sqlStore := db.InitTestDB(t)
store := store.DBstore{
@ -864,7 +1102,7 @@ func createTestEnv(t *testing.T) testEnvironment {
}}, nil).Maybe()
return testEnvironment{
secrets: secrets,
secrets: secretsService,
log: log,
configs: configs,
store: store,
@ -878,7 +1116,7 @@ func createTestEnv(t *testing.T) testEnvironment {
func createProvisioningSrvSut(t *testing.T) ProvisioningSrv {
t.Helper()
env := createTestEnv(t)
env := createTestEnv(t, testConfig)
return createProvisioningSrvSutFromEnv(t, &env)
}
@ -1133,3 +1371,99 @@ var testConfig = `
}
}
`
var testContactPointConfig = `
{
"template_files": {
"a": "template"
},
"alertmanager_config": {
"route": {
"receiver": "grafana-default-email"
},
"receivers": [
{
"name":"grafana-default-email",
"grafana_managed_receiver_configs":[
{
"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b",
"name":"grafana-default-email",
"type":"email",
"disableResolveMessage":false,
"settings":{
"addresses":"<example@email.com>"
},
"secureSettings":{}
}
]
},
{
"name":"multiple integrations",
"grafana_managed_receiver_configs":[
{
"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082",
"name":"multiple integrations",
"type":"prometheus-alertmanager",
"disableResolveMessage":true,
"settings":{
"basicAuthUser":"test",
"url":"http://localhost:9093"
},
"secureSettings":{
"basicAuthPassword":"testpass"
}
},
{
"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1",
"name":"multiple integrations",
"type":"discord",
"disableResolveMessage":false,
"settings":{
"avatar_url":"some avatar",
"url":"some url",
"use_discord_username":true
},
"secureSettings":{}
}
]
},
{
"name":"pagerduty test",
"grafana_managed_receiver_configs":[
{
"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d",
"name":"pagerduty test",
"type":"pagerduty",
"disableResolveMessage":false,
"settings":{
"client":"some client",
"severity":"criticalish"
},
"secureSettings":{
"integrationKey":"some key"
}
}
]
},
{
"name":"slack test",
"grafana_managed_receiver_configs":[
{
"uid":"cbfd0976-8228-4126-b672-4419f30a9e50",
"name":"slack test",
"type":"slack",
"disableResolveMessage":true,
"settings":{
"text":"title body test",
"title":"title test"
},
"secureSettings":{
"url":"some secure slack webhook"
}
}
]
}
]
}
}
`

View File

@ -186,6 +186,7 @@ func (api *API) authorize(method, path string) web.Handler {
// Grafana-only Provisioning Read Paths
case http.MethodGet + "/api/v1/provisioning/policies",
http.MethodGet + "/api/v1/provisioning/contact-points",
http.MethodGet + "/api/v1/provisioning/contact-points/export",
http.MethodGet + "/api/v1/provisioning/templates",
http.MethodGet + "/api/v1/provisioning/templates/{name}",
http.MethodGet + "/api/v1/provisioning/mute-timings",

View File

@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
require.Len(t, paths, 48)
require.Len(t, paths, 49)
ac := acmock.New()
api := &API{AccessControl: ac}

View File

@ -215,3 +215,48 @@ func AlertQueryExportFromAlertQuery(query models.AlertQuery) (definitions.AlertQ
Model: mdl,
}, nil
}
// AlertingFileExportFromEmbeddedContactPoints creates a definitions.AlertingFileExport DTO from []definitions.EmbeddedContactPoint.
func AlertingFileExportFromEmbeddedContactPoints(orgID int64, ecps []definitions.EmbeddedContactPoint) (definitions.AlertingFileExport, error) {
f := definitions.AlertingFileExport{APIVersion: 1}
cache := make(map[string]*definitions.ContactPointExport)
contactPoints := make([]*definitions.ContactPointExport, 0)
for _, ecp := range ecps {
c, ok := cache[ecp.Name]
if !ok {
c = &definitions.ContactPointExport{
OrgID: orgID,
Name: ecp.Name,
Receivers: make([]definitions.ReceiverExport, 0),
}
cache[ecp.Name] = c
contactPoints = append(contactPoints, c)
}
recv, err := ReceiverExportFromEmbeddedContactPoint(ecp)
if err != nil {
return definitions.AlertingFileExport{}, err
}
c.Receivers = append(c.Receivers, recv)
}
for _, c := range contactPoints {
f.ContactPoints = append(f.ContactPoints, *c)
}
return f, nil
}
// ReceiverExportFromEmbeddedContactPoint creates a definitions.ReceiverExport DTO from definitions.EmbeddedContactPoint.
func ReceiverExportFromEmbeddedContactPoint(contact definitions.EmbeddedContactPoint) (definitions.ReceiverExport, error) {
raw, err := contact.Settings.MarshalJSON()
if err != nil {
return definitions.ReceiverExport{}, err
}
return definitions.ReceiverExport{
UID: contact.UID,
Type: contact.Type,
Settings: raw,
DisableResolveMessage: contact.DisableResolveMessage,
}, nil
}

View File

@ -30,6 +30,7 @@ type ProvisioningApi interface {
RouteGetAlertRules(*contextmodel.ReqContext) response.Response
RouteGetAlertRulesExport(*contextmodel.ReqContext) response.Response
RouteGetContactpoints(*contextmodel.ReqContext) response.Response
RouteGetContactpointsExport(*contextmodel.ReqContext) response.Response
RouteGetMuteTiming(*contextmodel.ReqContext) response.Response
RouteGetMuteTimings(*contextmodel.ReqContext) response.Response
RouteGetPolicyTree(*contextmodel.ReqContext) response.Response
@ -98,6 +99,9 @@ func (f *ProvisioningApiHandler) RouteGetAlertRulesExport(ctx *contextmodel.ReqC
func (f *ProvisioningApiHandler) RouteGetContactpoints(ctx *contextmodel.ReqContext) response.Response {
return f.handleRouteGetContactpoints(ctx)
}
func (f *ProvisioningApiHandler) RouteGetContactpointsExport(ctx *contextmodel.ReqContext) response.Response {
return f.handleRouteGetContactpointsExport(ctx)
}
func (f *ProvisioningApiHandler) RouteGetMuteTiming(ctx *contextmodel.ReqContext) response.Response {
// Parse Path Parameters
nameParam := web.Params(ctx.Req)[":name"]
@ -316,6 +320,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApi, m *metrics
m,
),
)
group.Get(
toMacaronPath("/api/v1/provisioning/contact-points/export"),
api.authorize(http.MethodGet, "/api/v1/provisioning/contact-points/export"),
metrics.Instrument(
http.MethodGet,
"/api/v1/provisioning/contact-points/export",
api.Hooks.Wrap(srv.RouteGetContactpointsExport),
m,
),
)
group.Get(
toMacaronPath("/api/v1/provisioning/mute-timings/{name}"),
api.authorize(http.MethodGet, "/api/v1/provisioning/mute-timings/{name}"),

View File

@ -28,6 +28,10 @@ func (f *ProvisioningApiHandler) handleRouteGetContactpoints(ctx *contextmodel.R
return f.svc.RouteGetContactPoints(ctx)
}
func (f *ProvisioningApiHandler) handleRouteGetContactpointsExport(ctx *contextmodel.ReqContext) response.Response {
return f.svc.RouteGetContactPointsExport(ctx)
}
func (f *ProvisioningApiHandler) handleRoutePostContactpoints(ctx *contextmodel.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
return f.svc.RoutePostContactPoint(ctx, cp)
}

View File

@ -286,6 +286,12 @@
"format": "int64",
"type": "integer"
},
"contactPoints": {
"items": {
"$ref": "#/definitions/ContactPointExport"
},
"type": "array"
},
"groups": {
"items": {
"$ref": "#/definitions/AlertRuleGroupExport"
@ -537,6 +543,25 @@
"title": "Config is the top-level configuration for Alertmanager's config files.",
"type": "object"
},
"ContactPointExport": {
"properties": {
"name": {
"type": "string"
},
"orgId": {
"format": "int64",
"type": "integer"
},
"receivers": {
"items": {
"$ref": "#/definitions/ReceiverExport"
},
"type": "array"
}
},
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
"type": "object"
},
"ContactPoints": {
"items": {
"$ref": "#/definitions/EmbeddedContactPoint"
@ -2851,6 +2876,24 @@
"title": "Receiver configuration provides configuration on how to contact a receiver.",
"type": "object"
},
"ReceiverExport": {
"properties": {
"disableResolveMessage": {
"type": "boolean"
},
"settings": {
"$ref": "#/definitions/Json"
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.",
"type": "object"
},
"Regexp": {
"description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.",
"title": "Regexp is the representation of a compiled regular expression.",
@ -3681,6 +3724,7 @@
"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 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"
@ -3716,7 +3760,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"Userinfo": {
@ -3896,6 +3940,7 @@
"type": "object"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@ -3919,7 +3964,6 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@ -4079,13 +4123,13 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -4141,6 +4185,7 @@
"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",
@ -4284,7 +4329,6 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@ -4322,6 +4366,7 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@ -4631,13 +4676,6 @@
"get": {
"operationId": "RouteGetAlertRuleExport",
"parameters": [
{
"description": "Alert rule UID",
"in": "path",
"name": "UID",
"required": true,
"type": "string"
},
{
"default": false,
"description": "Whether to initiate a download of the file or not.",
@ -4651,6 +4689,13 @@
"in": "query",
"name": "format",
"type": "string"
},
{
"description": "Alert rule UID",
"in": "path",
"name": "UID",
"required": true,
"type": "string"
}
],
"produces": [
@ -4733,6 +4778,58 @@
]
}
},
"/api/v1/provisioning/contact-points/export": {
"get": {
"operationId": "RouteGetContactpointsExport",
"parameters": [
{
"default": false,
"description": "Whether to initiate a download of the file or not.",
"in": "query",
"name": "download",
"type": "boolean"
},
{
"default": "yaml",
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
"in": "query",
"name": "format",
"type": "string"
},
{
"default": false,
"description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.",
"in": "query",
"name": "decrypt",
"type": "boolean"
},
{
"description": "Filter by name",
"in": "query",
"name": "name",
"type": "string"
}
],
"responses": {
"200": {
"description": "AlertingFileExport",
"schema": {
"$ref": "#/definitions/AlertingFileExport"
}
},
"403": {
"description": "PermissionDenied",
"schema": {
"$ref": "#/definitions/PermissionDenied"
}
}
},
"summary": "Export all contact points in provisioning file format.",
"tags": [
"provisioning"
]
}
},
"/api/v1/provisioning/contact-points/{UID}": {
"delete": {
"consumes": [
@ -4882,18 +4979,6 @@
"get": {
"operationId": "RouteGetAlertRuleGroupExport",
"parameters": [
{
"in": "path",
"name": "FolderUID",
"required": true,
"type": "string"
},
{
"in": "path",
"name": "Group",
"required": true,
"type": "string"
},
{
"default": false,
"description": "Whether to initiate a download of the file or not.",
@ -4907,6 +4992,18 @@
"in": "query",
"name": "format",
"type": "string"
},
{
"in": "path",
"name": "FolderUID",
"required": true,
"type": "string"
},
{
"in": "path",
"name": "Group",
"required": true,
"type": "string"
}
],
"produces": [

View File

@ -0,0 +1,33 @@
package definitions
// AlertingFileExport is the full provisioned file export.
// swagger:model
type AlertingFileExport struct {
APIVersion int64 `json:"apiVersion" yaml:"apiVersion"`
Groups []AlertRuleGroupExport `json:"groups,omitempty" yaml:"groups,omitempty"`
ContactPoints []ContactPointExport `json:"contactPoints,omitempty" yaml:"contactPoints,omitempty"`
}
// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetAlertRulesExport RouteGetContactpointsExport RouteGetContactpointExport
type ExportQueryParams struct {
// Whether to initiate a download of the file or not.
// in: query
// required: false
// default: false
Download bool `json:"download"`
// Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.
// in: query
// required: false
// default: yaml
Format string `json:"format"`
}
// swagger:parameters RouteGetContactpointsExport RouteGetContactpointExport
type DecryptQueryParams struct {
// Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.
// in: query
// required: false
// default: false
Decrypt bool `json:"decrypt"`
}

View File

@ -190,21 +190,6 @@ type AlertRuleGroupMetadata struct {
Interval int64 `json:"interval"`
}
// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetAlertRulesExport
type ExportQueryParams struct {
// Whether to initiate a download of the file or not.
// in: query
// required: false
// default: false
Download bool `json:"download"`
// Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.
// in: query
// required: false
// default: yaml
Format string `json:"format"`
}
// swagger:model
type AlertRuleGroup struct {
Title string `json:"title"`
@ -213,13 +198,6 @@ type AlertRuleGroup struct {
Rules []ProvisionedAlertRule `json:"rules"`
}
// AlertingFileExport is the full provisioned file export.
// swagger:model
type AlertingFileExport struct {
APIVersion int64 `json:"apiVersion" yaml:"apiVersion"`
Groups []AlertRuleGroupExport `json:"groups" yaml:"groups"`
}
// AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1.
type AlertRuleGroupExport struct {
OrgID int64 `json:"orgId" yaml:"orgId"`

View File

@ -11,6 +11,14 @@ import (
// Responses:
// 200: ContactPoints
// swagger:route GET /api/v1/provisioning/contact-points/export provisioning stable RouteGetContactpointsExport
//
// Export all contact points in provisioning file format.
//
// Responses:
// 200: AlertingFileExport
// 403: PermissionDenied
// swagger:route POST /api/v1/provisioning/contact-points provisioning stable RoutePostContactpoints
//
// Create a contact point.
@ -43,14 +51,14 @@ import (
// Responses:
// 204: description: The contact point was deleted successfully.
// swagger:parameters RoutePutContactpoint RouteDeleteContactpoints
// swagger:parameters RoutePutContactpoint RouteDeleteContactpoints RouteGetContactpoint RouteGetContactpointExport
type ContactPointUIDReference struct {
// UID is the contact point unique identifier
// in:path
UID string
}
// swagger:parameters RouteGetContactpoints
// swagger:parameters RouteGetContactpoints RouteGetContactpointsExport
type ContactPointParams struct {
// Filter by name
// in: query
@ -91,6 +99,21 @@ type EmbeddedContactPoint struct {
Provenance string `json:"provenance,omitempty"`
}
// ContactPointExport is the provisioned file export of alerting.ContactPointV1.
type ContactPointExport struct {
OrgID int64 `json:"orgId" yaml:"orgId"`
Name string `json:"name" yaml:"name"`
Receivers []ReceiverExport `json:"receivers" yaml:"receivers"`
}
// ReceiverExport is the provisioned file export of alerting.ReceiverV1.
type ReceiverExport struct {
UID string `json:"uid" yaml:"uid"`
Type string `json:"type" yaml:"type"`
Settings RawMessage `json:"settings" yaml:"settings"`
DisableResolveMessage bool `json:"disableResolveMessage" yaml:"disableResolveMessage"`
}
const RedactedValue = "[REDACTED]"
func (e *EmbeddedContactPoint) ResourceID() string {

View File

@ -286,6 +286,12 @@
"format": "int64",
"type": "integer"
},
"contactPoints": {
"items": {
"$ref": "#/definitions/ContactPointExport"
},
"type": "array"
},
"groups": {
"items": {
"$ref": "#/definitions/AlertRuleGroupExport"
@ -537,6 +543,25 @@
"title": "Config is the top-level configuration for Alertmanager's config files.",
"type": "object"
},
"ContactPointExport": {
"properties": {
"name": {
"type": "string"
},
"orgId": {
"format": "int64",
"type": "integer"
},
"receivers": {
"items": {
"$ref": "#/definitions/ReceiverExport"
},
"type": "array"
}
},
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
"type": "object"
},
"ContactPoints": {
"items": {
"$ref": "#/definitions/EmbeddedContactPoint"
@ -2851,6 +2876,24 @@
"title": "Receiver configuration provides configuration on how to contact a receiver.",
"type": "object"
},
"ReceiverExport": {
"properties": {
"disableResolveMessage": {
"type": "boolean"
},
"settings": {
"$ref": "#/definitions/Json"
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.",
"type": "object"
},
"Regexp": {
"description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.",
"title": "Regexp is the representation of a compiled regular expression.",
@ -3681,7 +3724,7 @@
"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"
@ -4142,7 +4185,6 @@
"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",
@ -4324,7 +4366,6 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@ -6415,13 +6456,6 @@
"get": {
"operationId": "RouteGetAlertRuleExport",
"parameters": [
{
"description": "Alert rule UID",
"in": "path",
"name": "UID",
"required": true,
"type": "string"
},
{
"default": false,
"description": "Whether to initiate a download of the file or not.",
@ -6435,6 +6469,13 @@
"in": "query",
"name": "format",
"type": "string"
},
{
"description": "Alert rule UID",
"in": "path",
"name": "UID",
"required": true,
"type": "string"
}
],
"produces": [
@ -6517,6 +6558,58 @@
]
}
},
"/api/v1/provisioning/contact-points/export": {
"get": {
"operationId": "RouteGetContactpointsExport",
"parameters": [
{
"default": false,
"description": "Whether to initiate a download of the file or not.",
"in": "query",
"name": "download",
"type": "boolean"
},
{
"default": "yaml",
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
"in": "query",
"name": "format",
"type": "string"
},
{
"default": false,
"description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.",
"in": "query",
"name": "decrypt",
"type": "boolean"
},
{
"description": "Filter by name",
"in": "query",
"name": "name",
"type": "string"
}
],
"responses": {
"200": {
"description": "AlertingFileExport",
"schema": {
"$ref": "#/definitions/AlertingFileExport"
}
},
"403": {
"description": "PermissionDenied",
"schema": {
"$ref": "#/definitions/PermissionDenied"
}
}
},
"summary": "Export all contact points in provisioning file format.",
"tags": [
"provisioning"
]
}
},
"/api/v1/provisioning/contact-points/{UID}": {
"delete": {
"consumes": [
@ -6666,18 +6759,6 @@
"get": {
"operationId": "RouteGetAlertRuleGroupExport",
"parameters": [
{
"in": "path",
"name": "FolderUID",
"required": true,
"type": "string"
},
{
"in": "path",
"name": "Group",
"required": true,
"type": "string"
},
{
"default": false,
"description": "Whether to initiate a download of the file or not.",
@ -6691,6 +6772,18 @@
"in": "query",
"name": "format",
"type": "string"
},
{
"in": "path",
"name": "FolderUID",
"required": true,
"type": "string"
},
{
"in": "path",
"name": "Group",
"required": true,
"type": "string"
}
],
"produces": [

View File

@ -2004,13 +2004,6 @@
"summary": "Export an alert rule in provisioning file format.",
"operationId": "RouteGetAlertRuleExport",
"parameters": [
{
"type": "string",
"description": "Alert rule UID",
"name": "UID",
"in": "path",
"required": true
},
{
"type": "boolean",
"default": false,
@ -2024,6 +2017,13 @@
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
"name": "format",
"in": "query"
},
{
"type": "string",
"description": "Alert rule UID",
"name": "UID",
"in": "path",
"required": true
}
],
"responses": {
@ -2099,6 +2099,59 @@
}
}
},
"/api/v1/provisioning/contact-points/export": {
"get": {
"tags": [
"provisioning",
"stable"
],
"summary": "Export all contact points in provisioning file format.",
"operationId": "RouteGetContactpointsExport",
"parameters": [
{
"type": "boolean",
"default": false,
"description": "Whether to initiate a download of the file or not.",
"name": "download",
"in": "query"
},
{
"type": "string",
"default": "yaml",
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
"name": "format",
"in": "query"
},
{
"type": "boolean",
"default": false,
"description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.",
"name": "decrypt",
"in": "query"
},
{
"type": "string",
"description": "Filter by name",
"name": "name",
"in": "query"
}
],
"responses": {
"200": {
"description": "AlertingFileExport",
"schema": {
"$ref": "#/definitions/AlertingFileExport"
}
},
"403": {
"description": "PermissionDenied",
"schema": {
"$ref": "#/definitions/PermissionDenied"
}
}
}
}
},
"/api/v1/provisioning/contact-points/{UID}": {
"put": {
"consumes": [
@ -2262,18 +2315,6 @@
"summary": "Export an alert rule group in provisioning file format.",
"operationId": "RouteGetAlertRuleGroupExport",
"parameters": [
{
"type": "string",
"name": "FolderUID",
"in": "path",
"required": true
},
{
"type": "string",
"name": "Group",
"in": "path",
"required": true
},
{
"type": "boolean",
"default": false,
@ -2287,6 +2328,18 @@
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
"name": "format",
"in": "query"
},
{
"type": "string",
"name": "FolderUID",
"in": "path",
"required": true
},
{
"type": "string",
"name": "Group",
"in": "path",
"required": true
}
],
"responses": {
@ -3054,6 +3107,12 @@
"type": "integer",
"format": "int64"
},
"contactPoints": {
"type": "array",
"items": {
"$ref": "#/definitions/ContactPointExport"
}
},
"groups": {
"type": "array",
"items": {
@ -3303,6 +3362,25 @@
}
}
},
"ContactPointExport": {
"type": "object",
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
"properties": {
"name": {
"type": "string"
},
"orgId": {
"type": "integer",
"format": "int64"
},
"receivers": {
"type": "array",
"items": {
"$ref": "#/definitions/ReceiverExport"
}
}
}
},
"ContactPoints": {
"type": "array",
"items": {
@ -5621,6 +5699,24 @@
}
}
},
"ReceiverExport": {
"type": "object",
"title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.",
"properties": {
"disableResolveMessage": {
"type": "boolean"
},
"settings": {
"$ref": "#/definitions/Json"
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
"Regexp": {
"description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.",
"type": "object",
@ -6451,7 +6547,7 @@
}
},
"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": {
@ -6918,7 +7014,6 @@
"$ref": "#/definitions/gettableSilences"
},
"integration": {
"description": "Integration integration",
"type": "object",
"required": [
"name",
@ -7102,7 +7197,6 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"active",

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"sort"
"strings"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/prometheus/alertmanager/config"
@ -15,7 +16,9 @@ import (
apimodels "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/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
)
@ -42,9 +45,15 @@ type ContactPointQuery struct {
// Optionally filter by name.
Name string
OrgID int64
// Optionally decrypt secure settings, requires OrgAdmin.
Decrypt bool
}
func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery) ([]apimodels.EmbeddedContactPoint, error) {
// GetContactPoints returns contact points. If q.Decrypt is true and the user is an OrgAdmin, decrypted secure settings are included instead of redacted ones.
func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery, u *user.SignedInUser) ([]apimodels.EmbeddedContactPoint, error) {
if q.Decrypt && (u == nil || !u.HasRole(org.RoleAdmin)) {
return nil, fmt.Errorf("%w: decrypting secure settings requires Org Admin", ErrPermissionDenied)
}
revision, err := getLastConfiguration(ctx, q.OrgID, ecp.amStore)
if err != nil {
return nil, err
@ -82,13 +91,23 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP
if decryptedValue == "" {
continue
}
embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue)
if q.Decrypt {
embeddedContactPoint.Settings.Set(k, decryptedValue)
} else {
embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue)
}
}
contactPoints = append(contactPoints, embeddedContactPoint)
}
sort.SliceStable(contactPoints, func(i, j int) bool {
return contactPoints[i].Name < contactPoints[j].Name
switch strings.Compare(contactPoints[i].Name, contactPoints[j].Name) {
case -1:
return true
case 1:
return false
}
return contactPoints[i].UID < contactPoints[j].UID
})
return contactPoints, nil
}

View File

@ -2,6 +2,7 @@ package provisioning
import (
"context"
"encoding/json"
"fmt"
"testing"
@ -13,49 +14,51 @@ import (
"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/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/database"
"github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/user"
)
func TestContactPointService(t *testing.T) {
sqlStore := db.InitTestDB(t)
secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
t.Run("service gets contact points from AM config", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1))
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Len(t, cps, 1)
require.Equal(t, "email receiver", cps[0].Name)
require.Equal(t, "slack receiver", cps[0].Name)
})
t.Run("service filters contact points by name", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
_, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
q := ContactPointQuery{
OrgID: 1,
Name: "email receiver",
Name: "slack receiver",
}
cps, err := sut.GetContactPoints(context.Background(), q)
cps, err := sut.GetContactPoints(context.Background(), q, nil)
require.NoError(t, err)
require.Len(t, cps, 1)
require.Equal(t, "email receiver", cps[0].Name)
require.Equal(t, "slack receiver", cps[0].Name)
})
t.Run("service stitches contact point into org's AM config", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
_, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1))
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Len(t, cps, 2)
require.Equal(t, "test-contact-point", cps[1].Name)
@ -64,14 +67,14 @@ func TestContactPointService(t *testing.T) {
t.Run("it's possible to use a custom uid", func(t *testing.T) {
customUID := "1337"
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp.UID = customUID
_, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1))
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Len(t, cps, 2)
require.Equal(t, customUID, cps[1].UID)
@ -79,7 +82,7 @@ func TestContactPointService(t *testing.T) {
t.Run("it's not possible to use the same uid twice", func(t *testing.T) {
customUID := "1337"
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp.UID = customUID
@ -91,7 +94,7 @@ func TestContactPointService(t *testing.T) {
})
t.Run("create rejects contact points that fail validation", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp.Type = ""
@ -101,7 +104,7 @@ func TestContactPointService(t *testing.T) {
})
t.Run("update rejects contact points with no settings", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
@ -113,7 +116,7 @@ func TestContactPointService(t *testing.T) {
})
t.Run("update rejects contact points with no type", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
@ -125,7 +128,7 @@ func TestContactPointService(t *testing.T) {
})
t.Run("update rejects contact points which fail validation after merging", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
@ -137,9 +140,9 @@ func TestContactPointService(t *testing.T) {
})
t.Run("default provenance of contact points is none", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1))
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Equal(t, models.ProvenanceNone, models.Provenance(cps[0].Provenance))
@ -191,13 +194,13 @@ func TestContactPointService(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, test.from)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1))
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, test.from, models.Provenance(cps[1].Provenance))
@ -206,7 +209,7 @@ func TestContactPointService(t *testing.T) {
if test.errNil {
require.NoError(t, err)
cps, err = sut.GetContactPoints(context.Background(), cpsQuery(1))
cps, err = sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, test.to, models.Provenance(cps[1].Provenance))
@ -218,7 +221,7 @@ func TestContactPointService(t *testing.T) {
})
t.Run("service respects concurrency token when updating", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
sut := createContactPointServiceSut(t, secretsService)
newCp := createTestContactPoint()
q := models.GetLatestAlertmanagerConfigurationQuery{
OrgID: 1,
@ -236,6 +239,41 @@ func TestContactPointService(t *testing.T) {
})
}
func TestContactPointServiceDecryptRedact(t *testing.T) {
sqlStore := db.InitTestDB(t)
secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
t.Run("GetContactPoints gets redacted contact points by default", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil)
require.NoError(t, err)
require.Len(t, cps, 1)
require.Equal(t, "slack receiver", cps[0].Name)
require.Equal(t, definitions.RedactedValue, cps[0].Settings.Get("url").MustString())
})
t.Run("GetContactPoints errors when Decrypt = true and user not Org Admin", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
q := cpsQuery(1)
q.Decrypt = true
_, err := sut.GetContactPoints(context.Background(), q, nil)
require.ErrorIs(t, err, ErrPermissionDenied)
})
t.Run("GetContactPoints gets decrypted contact points when Decrypt = true and user is Org Admin", func(t *testing.T) {
sut := createContactPointServiceSut(t, secretsService)
q := cpsQuery(1)
q.Decrypt = true
cps, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{OrgID: 1, OrgRole: org.RoleAdmin})
require.NoError(t, err)
require.Len(t, cps, 1)
require.Equal(t, "slack receiver", cps[0].Name)
require.Equal(t, "secure url", cps[0].Settings.Get("url").MustString())
})
}
func TestContactPointInUse(t *testing.T) {
result := isContactPointInUse("test", []*definitions.Route{
{
@ -267,9 +305,21 @@ func TestContactPointInUse(t *testing.T) {
require.False(t, result)
}
func createContactPointServiceSut(secretService secrets.Service) *ContactPointService {
func createContactPointServiceSut(t *testing.T, secretService secrets.Service) *ContactPointService {
// Encrypt secure settings.
c := &definitions.PostableUserConfig{}
err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c)
require.NoError(t, err)
err = c.EncryptConfig(func(ctx context.Context, payload []byte) ([]byte, error) {
return secretService.Encrypt(ctx, payload, secrets.WithoutScope())
})
require.NoError(t, err)
raw, err := json.Marshal(c)
require.NoError(t, err)
return &ContactPointService{
amStore: newFakeAMConfigStore(),
amStore: newFakeAMConfigStore(string(raw)),
provenanceStore: NewFakeProvisioningStore(),
xact: newNopTransactionManager(),
encryptionService: secretService,

View File

@ -1,6 +1,10 @@
package provisioning
import "fmt"
import (
"errors"
"fmt"
)
var ErrValidation = fmt.Errorf("invalid object specification")
var ErrNotFound = fmt.Errorf("object not found")
var ErrPermissionDenied = errors.New("permission denied")

View File

@ -219,7 +219,7 @@ func TestNotificationPolicyService(t *testing.T) {
func createNotificationPolicyServiceSut() *NotificationPolicyService {
return &NotificationPolicyService{
amStore: newFakeAMConfigStore(),
amStore: newFakeAMConfigStore(defaultAlertmanagerConfigJSON),
provenanceStore: NewFakeProvisioningStore(),
xact: newNopTransactionManager(),
log: log.NewNopLogger(),

View File

@ -42,13 +42,11 @@ const defaultAlertmanagerConfigJSON = `
"name": "a new receiver",
"grafana_managed_receiver_configs": [{
"uid": "",
"name": "email receiver",
"type": "email",
"name": "slack receiver",
"type": "slack",
"disableResolveMessage": false,
"settings": {
"addresses": "\u003canother@email.com\u003e"
},
"secureFields": {}
"settings": {},
"secureSettings": {"url":"secure url"}
}]
}]
}
@ -60,10 +58,10 @@ type fakeAMConfigStore struct {
lastSaveCommand *models.SaveAlertmanagerConfigurationCmd
}
func newFakeAMConfigStore() *fakeAMConfigStore {
func newFakeAMConfigStore(config string) *fakeAMConfigStore {
return &fakeAMConfigStore{
config: models.AlertConfiguration{
AlertmanagerConfiguration: defaultAlertmanagerConfigJSON,
AlertmanagerConfiguration: config,
ConfigurationVersion: "v1",
Default: true,
OrgID: 1,
@ -168,6 +166,7 @@ func (m *MockAMConfigStore_Expecter) SaveSucceedsIntercept(intercepted *models.S
func (m *MockProvisioningStore_Expecter) GetReturns(p models.Provenance) *MockProvisioningStore_Expecter {
m.GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(p, nil)
m.GetProvenances(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
return m
}

View File

@ -37,7 +37,7 @@ func (c *defaultContactPointProvisioner) Provision(ctx context.Context,
if _, exists := cpsCache[contactPointsConfig.OrgID]; !exists {
cps, err := c.contactPointService.GetContactPoints(ctx, provisioning.ContactPointQuery{
OrgID: contactPointsConfig.OrgID,
})
}, nil)
if err != nil {
return err
}

View File

@ -185,6 +185,11 @@ describe('Receivers', () => {
return permissions.includes(action as AccessControlAction);
});
// respond with "true" when asked if we are an administrator
mocks.contextSrv.hasRole.mockImplementation((role: string) => {
return role === 'Admin';
});
mocks.contextSrv.hasAccess.mockImplementation(() => true);
});

View File

@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, useStyles2 } from '@grafana/ui';
import { Button, Dropdown, Icon, Menu, MenuItem, useStyles2 } from '@grafana/ui';
interface Props {
title: string;
@ -13,6 +13,7 @@ interface Props {
addButtonTo: string;
className?: string;
showButton?: boolean;
exportLink?: string;
}
export const ReceiversSection = ({
@ -23,8 +24,11 @@ export const ReceiversSection = ({
addButtonTo,
children,
showButton = true,
exportLink,
}: React.PropsWithChildren<Props>) => {
const styles = useStyles2(getStyles);
const showMore = Boolean(exportLink);
const newMenu = <Menu>{exportLink && <MenuItem url={exportLink} label="Export all" target="_blank" />}</Menu>;
return (
<Stack direction="column" gap={2}>
<div className={cx(styles.heading, className)}>
@ -32,13 +36,23 @@ export const ReceiversSection = ({
<h4>{title}</h4>
<div className={styles.description}>{description}</div>
</div>
{showButton && (
<Link to={addButtonTo}>
<Button type="button" icon="plus">
{addButtonLabel}
</Button>
</Link>
)}
<Stack direction="row" gap={0.5}>
{showButton && (
<Link to={addButtonTo}>
<Button type="button" icon="plus">
{addButtonLabel}
</Button>
</Link>
)}
{showMore && (
<Dropdown overlay={newMenu}>
<Button variant="secondary">
More
<Icon name="angle-down" />
</Button>
</Dropdown>
)}
</Stack>
</div>
{children}
</Stack>

View File

@ -8,6 +8,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction, ContactPointsState, NotifiersState, ReceiversState, useDispatch } from 'app/types';
import { isOrgAdmin } from '../../../../plugins/admin/permissions';
import { useGetContactPointsState } from '../../api/receiversApi';
import { Authorize } from '../../components/Authorize';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
@ -16,9 +17,10 @@ import { getAlertTableStyles } from '../../styles/table';
import { SupportedPlugin } from '../../types/pluginBridges';
import { getNotificationsPermissions } from '../../utils/access-control';
import { isReceiverUsed } from '../../utils/alertmanager';
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { extractNotifierTypeCounts } from '../../utils/receivers';
import { createUrl } from '../../utils/url';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { ProvisioningBadge } from '../Provisioning';
import { ActionIcon } from '../rules/ActionIcon';
@ -65,6 +67,9 @@ interface ActionProps {
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
provisioning: {
read: AccessControlAction;
};
};
alertManagerName: string;
receiverName: string;
@ -83,6 +88,25 @@ function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps
);
}
function ExportAction({ permissions, receiverName }: ActionProps) {
return (
<Authorize actions={[permissions.provisioning.read]} fallback={isOrgAdmin()}>
<ActionIcon
data-testid="export"
to={createUrl(`/api/v1/provisioning/contact-points/export/`, {
download: 'true',
format: 'yaml',
decrypt: isOrgAdmin().toString(),
name: receiverName,
})}
tooltip={isOrgAdmin() ? 'Export contact point' : 'Export redacted contact point'}
icon="download-alt"
target="_blank"
/>
</Authorize>
);
}
interface ReceiverErrorProps {
errorCount: number;
errorDetail?: string;
@ -276,6 +300,9 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
const [receiverToDelete, setReceiverToDelete] = useState<string>();
const [showCannotDeleteReceiverModal, setShowCannotDeleteReceiverModal] = useState(false);
const isGrafanaAM = alertManagerName === GRAFANA_RULES_SOURCE_NAME;
const showExport = isGrafanaAM && contextSrv.hasAccess(permissions.provisioning.read, isOrgAdmin());
const onClickDeleteReceiver = (receiverName: string): void => {
if (isReceiverUsed(receiverName, config)) {
setShowCannotDeleteReceiverModal(true);
@ -330,6 +357,15 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)}
addButtonLabel={'Add contact point'}
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
exportLink={
showExport
? createUrl('/api/v1/provisioning/contact-points/export', {
download: 'true',
format: 'yaml',
decrypt: isOrgAdmin().toString(),
})
: undefined
}
>
<DynamicTable
items={rows}
@ -398,6 +434,9 @@ function useGetColumns(
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
provisioning: {
read: AccessControlAction;
};
},
isVanillaAM: boolean
): RowTableColumnProps[] {
@ -406,6 +445,8 @@ function useGetColumns(
const enableHealthColumn =
errorStateAvailable || Object.values(configHealth.contactPoints).some((cp) => cp.matchingRoutes === 0);
const isGrafanaAlertManager = alertManagerName === GRAFANA_RULES_SOURCE_NAME;
const baseColumns: RowTableColumnProps[] = [
{
id: 'name',
@ -456,7 +497,10 @@ function useGetColumns(
id: 'actions',
label: 'Actions',
renderCell: ({ data: { provisioned, name } }) => (
<Authorize actions={[permissions.update, permissions.delete]}>
<Authorize
actions={[permissions.update, permissions.delete, permissions.provisioning.read]}
fallback={isOrgAdmin()}
>
<div className={tableStyles.actionsCell}>
{!isVanillaAM && !provisioned && (
<UpdateActions
@ -469,6 +513,9 @@ function useGetColumns(
{(isVanillaAM || provisioned) && (
<ViewAction permissions={permissions} alertManagerName={alertManagerName} receiverName={name} />
)}
{isGrafanaAlertManager && (
<ExportAction permissions={permissions} alertManagerName={alertManagerName} receiverName={name} />
)}
</div>
</Authorize>
),

View File

@ -91,6 +91,7 @@ export function getNotificationsPermissions(rulesSourceName: string) {
create: notificationsPermissions.create[sourceType],
update: notificationsPermissions.update[sourceType],
delete: notificationsPermissions.delete[sourceType],
provisioning: provisioningPermissions,
};
}