mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
a7c639f16e
commit
13121d3234
@ -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.
|
||||
|
||||
|
@ -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` | | | | |
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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 = "as
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}"),
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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": [
|
||||
|
33
pkg/services/ngalert/api/tooling/definitions/provisioning.go
Normal file
33
pkg/services/ngalert/api/tooling/definitions/provisioning.go
Normal 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"`
|
||||
}
|
@ -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"`
|
||||
|
@ -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 {
|
||||
|
@ -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": [
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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")
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
),
|
||||
|
@ -91,6 +91,7 @@ export function getNotificationsPermissions(rulesSourceName: string) {
|
||||
create: notificationsPermissions.create[sourceType],
|
||||
update: notificationsPermissions.update[sourceType],
|
||||
delete: notificationsPermissions.delete[sourceType],
|
||||
provisioning: provisioningPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user