Alerting: Pass yaml as a query param in export request (#62751)

* Set YAML as default value for exporting alert rules

* use YAML format for rule list export

Co-authored-by: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com>

* lint

* Add new format query param to swagger+docs

* Fix broken test

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Matt Jacobson <matthew.jacobson@grafana.com>
This commit is contained in:
Sonia Aguilar 2023-02-02 17:10:02 +01:00 committed by GitHub
parent 99a16d27c1
commit 753c84f825
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 173 additions and 40 deletions

View File

@ -244,10 +244,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. |
| 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
@ -322,11 +323,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. |
| 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
@ -381,9 +383,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. |
| 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

View File

@ -428,12 +428,22 @@ func determineProvenance(ctx *contextmodel.ReqContext) alerting_models.Provenanc
}
func exportResponse(c *contextmodel.ReqContext, body any) response.Response {
format := "json"
var format = "yaml"
acceptHeader := c.Req.Header.Get("Accept")
if strings.Contains(acceptHeader, "yaml") && !strings.Contains(acceptHeader, "json") {
if strings.Contains(acceptHeader, "yaml") {
format = "yaml"
}
if strings.Contains(acceptHeader, "json") {
format = "json"
}
queryFormat := c.Query("format")
if queryFormat == "yaml" || queryFormat == "json" {
format = queryFormat
}
download := c.QueryBoolWithDefault("download", false)
if download {
r := response.JSONDownload

View File

@ -410,6 +410,34 @@ func TestProvisioningApi(t *testing.T) {
require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type"))
})
t.Run("query format contains yaml, GET returns text yaml", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
insertRule(t, sut, createTestAlertRule("rule", 1))
rc.Context.Req.Form.Set("format", "yaml")
response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group")
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type"))
})
t.Run("query format contains unknown value, GET returns text yaml", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
insertRule(t, sut, createTestAlertRule("rule", 1))
rc.Context.Req.Form.Set("format", "foo")
response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group")
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()
@ -474,12 +502,31 @@ func TestProvisioningApi(t *testing.T) {
require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition"))
})
t.Run("yaml body content is the default", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
insertRule(t, sut, createTestAlertRule("rule1", 1))
insertRule(t, sut, createTestAlertRule("rule2", 1))
expectedResponse := "apiVersion: 1\ngroups:\n - orgId: 1\n name: my-cool-group\n folder" +
": Folder Title\n interval: 1m\n rules:\n - uid: rule1\n title: rule1\n" +
" condition: A\n data:\n - refId: A\n datasourceUid" +
": \"\"\n model:\n conditions:\n - evaluator:\n" +
" params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n - uid: rule2\n title: rule2\n condition: A\n data:\n - refId: A\n datasourceUid: \"\"\n model:\n conditions:\n - evaluator:\n params:\n - 3\n type: gt\n operator:\n type: and\n query:\n params:\n - A\n reducer:\n type: last\n type: query\n datasource:\n type: __expr__\n uid: __expr__\n expression: 1==0\n intervalMs: 1000\n maxDataPoints: 43200\n refId: A\n type: math\n noDataState: OK\n execErrState: OK\n for: 0s\n isPaused: false\n"
response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group")
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
t.Run("json body content is as expected", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rc := createTestRequestCtx()
insertRule(t, sut, createTestAlertRule("rule1", 1))
insertRule(t, sut, createTestAlertRule("rule2", 1))
rc.Context.Req.Header.Add("Accept", "application/json")
expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false},{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}`
response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group")
@ -613,6 +660,7 @@ func TestProvisioningApi(t *testing.T) {
expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"my-cool-group","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}`
rc.Context.Req.Header.Add("Accept", "application/json")
response := sut.RouteGetAlertRuleExport(&rc, "rule1")
require.Equal(t, 200, response.Status())
@ -729,6 +777,7 @@ func TestProvisioningApi(t *testing.T) {
insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb"))
insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb"))
rc.Context.Req.Header.Add("Accept", "application/json")
expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}`
response := sut.RouteGetAlertRulesExport(&rc)

View File

@ -363,6 +363,10 @@
"external"
],
"type": "string"
},
"numExternalAlertmanagers": {
"format": "int64",
"type": "integer"
}
},
"type": "object"
@ -3290,6 +3294,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.",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -3325,7 +3330,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": {
@ -3502,7 +3507,6 @@
"type": "object"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@ -3526,6 +3530,7 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@ -3630,7 +3635,6 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@ -3686,6 +3690,7 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
@ -3740,14 +3745,12 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array"
},
"integration": {
"description": "Integration integration",
"properties": {
"lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@ -4113,6 +4116,13 @@
"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"
}
],
"responses": {
@ -4244,6 +4254,13 @@
"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"
}
],
"produces": [
@ -4493,6 +4510,13 @@
"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"
}
],
"produces": [

View File

@ -249,6 +249,12 @@ type ExportQueryParams struct {
// 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

View File

@ -363,6 +363,10 @@
"external"
],
"type": "string"
},
"numExternalAlertmanagers": {
"format": "int64",
"type": "integer"
}
},
"type": "object"
@ -3290,6 +3294,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.",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -3325,7 +3330,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": {
@ -3502,6 +3507,7 @@
"type": "object"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@ -3525,7 +3531,6 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@ -3630,6 +3635,7 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@ -3691,6 +3697,7 @@
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -3927,7 +3934,6 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@ -5784,6 +5790,13 @@
"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"
}
],
"responses": {
@ -5915,6 +5928,13 @@
"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"
}
],
"produces": [
@ -6164,6 +6184,13 @@
"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"
}
],
"produces": [

View File

@ -1761,6 +1761,13 @@
"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"
}
],
"responses": {
@ -1901,6 +1908,13 @@
"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"
}
],
"responses": {
@ -2157,6 +2171,13 @@
"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"
}
],
"responses": {
@ -2975,6 +2996,10 @@
"internal",
"external"
]
},
"numExternalAlertmanagers": {
"type": "integer",
"format": "int64"
}
}
},
@ -5905,8 +5930,9 @@
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"type": "object",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -6117,6 +6143,7 @@
}
},
"alertGroup": {
"description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@ -6141,7 +6168,6 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@ -6247,6 +6273,7 @@
}
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object",
"required": [
"labels",
@ -6310,6 +6337,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@ -6550,7 +6578,6 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"active",

View File

@ -5,7 +5,7 @@ import { useAsyncFn, useInterval } from 'react-use';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { BackendSrvRequest, getBackendSrv, logInfo } from '@grafana/runtime';
import { logInfo } from '@grafana/runtime';
import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useDispatch } from 'app/types';
@ -33,20 +33,7 @@ const VIEWS = {
state: RuleListStateView,
};
const onExport = async () => {
const exportURL = `/api/v1/provisioning/alert-rules/export?download=true`;
const options: BackendSrvRequest = {
url: exportURL,
headers: { Accept: 'yaml' },
responseType: 'blob',
};
const blob = await getBackendSrv().get(exportURL, undefined, undefined, options);
const fileUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = fileUrl;
downloadLink.download = 'export.yaml';
downloadLink.click();
};
const onExport = () => window.open('/api/v1/provisioning/alert-rules/export?download=true&format=yaml');
const RuleList = withErrorBoundary(
() => {