Alerting: Export of alert rules in HCL format (#73166)

* import hashicopr/hcl/v2
* add hcl package and export to HCL
* annotate export structs
---------

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Yuri Tseretyan
2023-09-11 11:48:23 -04:00
committed by GitHub
parent 5d89c15851
commit dce492642a
11 changed files with 308 additions and 34 deletions

View File

@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ngalert/api/hcl"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
@@ -498,7 +499,7 @@ func extractExportRequest(c *contextmodel.ReqContext) definitions.ExportQueryPar
}
queryFormat := c.Query("format")
if queryFormat == "yaml" || queryFormat == "json" {
if queryFormat == "yaml" || queryFormat == "json" || queryFormat == "hcl" {
format = queryFormat
}
@@ -512,6 +513,10 @@ func extractExportRequest(c *contextmodel.ReqContext) definitions.ExportQueryPar
func exportResponse(c *contextmodel.ReqContext, body definitions.AlertingFileExport) response.Response {
params := extractExportRequest(c)
if params.Format == "hcl" {
return exportHcl(params.Download, body)
}
if params.Download {
r := response.JSONDownload
if params.Format == "yaml" {
@@ -526,3 +531,43 @@ func exportResponse(c *contextmodel.ReqContext, body definitions.AlertingFileExp
}
return r(http.StatusOK, body)
}
func exportHcl(download bool, body definitions.AlertingFileExport) response.Response {
resources := make([]hcl.Resource, 0, len(body.Groups)+len(body.ContactPoints)+len(body.Policies))
for idx, group := range body.Groups {
gr := group
resources = append(resources, hcl.Resource{
Type: "grafana_rule_group",
Name: fmt.Sprintf("rule_group_%04d", idx),
Body: &gr,
})
}
// TODO implement support.
// for idx, cp := range ex.ContactPoints {
// resources = append(resources, resourceBlock{
// Type: "grafana_contact_point",
// Name: fmt.Sprintf("contact_point_%d", idx),
// Body: &cp,
// })
// }
// for idx, cp := range ex.Policies {
// resources = append(resources, resourceBlock{
// Type: "grafana_notification_policy",
// Name: fmt.Sprintf("notification_policy_%d", idx),
// Body: &cp,
// })
//
hclBody, err := hcl.Encode(resources...)
if err != nil {
return response.Error(500, "body hcl encode", err)
}
resp := response.Respond(http.StatusOK, hclBody)
if download {
return resp.
SetHeader("Content-Type", "application/terraform+hcl").
SetHeader("Content-Disposition", `attachment;filename=export.tf`)
}
return resp.SetHeader("Content-Type", "text/hcl")
}

View File

@@ -566,6 +566,106 @@ func TestProvisioningApi(t *testing.T) {
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
t.Run("hcl body content is as expected", func(t *testing.T) {
sut := createProvisioningSrvSut(t)
rule1 := createTestAlertRule("rule1", 1)
rule1.Labels = map[string]string{
"test": "label",
}
rule1.Annotations = map[string]string{
"test": "annotation",
}
rule1.NoDataState = definitions.Alerting
rule1.ExecErrState = definitions.ErrorErrState
insertRule(t, sut, rule1)
insertRule(t, sut, createTestAlertRule("rule2", 1))
expectedResponse := `resource "grafana_rule_group" "rule_group_0000" {
org_id = 1
name = "my-cool-group"
folder_uid = "folder-uid"
interval_seconds = 60
rule {
name = "rule1"
condition = "A"
data {
ref_id = "A"
query_type = ""
relative_time_range {
from = 0
to = 0
}
datasource_uid = ""
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\"}"
}
no_data_state = "Alerting"
exec_err_state = "Error"
for = 0
annotations = {
test = "annotation"
}
labels = {
test = "label"
}
is_paused = false
}
rule {
name = "rule2"
condition = "A"
data {
ref_id = "A"
query_type = ""
relative_time_range {
from = 0
to = 0
}
datasource_uid = ""
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\"}"
}
no_data_state = "OK"
exec_err_state = "OK"
for = 0
annotations = null
labels = null
is_paused = false
}
}
`
rc := createTestRequestCtx()
rc.Context.Req.Form.Set("format", "hcl")
rc.Context.Req.Form.Set("download", "false")
response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group")
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
require.Equal(t, "text/hcl", rc.Resp.Header().Get("Content-Type"))
t.Run("and add specific headers if download=true", func(t *testing.T) {
rc := createTestRequestCtx()
rc.Context.Req.Form.Set("format", "hcl")
rc.Context.Req.Form.Set("download", "true")
response := sut.RouteGetAlertRuleGroupExport(&rc, "folder-uid", "my-cool-group")
response.WriteTo(&rc)
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
require.Equal(t, "application/terraform+hcl", rc.Resp.Header().Get("Content-Type"))
require.Equal(t, `attachment;filename=export.tf`, rc.Resp.Header().Get("Content-Disposition"))
})
})
})
t.Run("alert rule", func(t *testing.T) {

View File

@@ -151,11 +151,13 @@ func AlertRuleGroupExportFromAlertRuleGroupWithFolderTitle(d models.AlertRuleGro
rules = append(rules, alert)
}
return definitions.AlertRuleGroupExport{
OrgID: d.OrgID,
Name: d.Title,
Folder: d.FolderTitle,
Interval: model.Duration(time.Duration(d.Interval) * time.Second),
Rules: rules,
OrgID: d.OrgID,
Name: d.Title,
Folder: d.FolderTitle,
FolderUID: d.FolderUID,
Interval: model.Duration(time.Duration(d.Interval) * time.Second),
IntervalSeconds: d.Interval,
Rules: rules,
}, nil
}
@@ -184,6 +186,7 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE
UID: rule.UID,
Title: rule.Title,
For: model.Duration(rule.For),
ForSeconds: int64(rule.For.Seconds()),
Condition: rule.Condition,
Data: data,
DashboardUID: dashboardUID,
@@ -207,12 +210,13 @@ func AlertQueryExportFromAlertQuery(query models.AlertQuery) (definitions.AlertQ
return definitions.AlertQueryExport{
RefID: query.RefID,
QueryType: query.QueryType,
RelativeTimeRange: definitions.RelativeTimeRange{
From: definitions.Duration(query.RelativeTimeRange.From),
To: definitions.Duration(query.RelativeTimeRange.To),
RelativeTimeRange: definitions.RelativeTimeRangeExport{
FromSeconds: int64(time.Duration(query.RelativeTimeRange.From).Seconds()),
ToSeconds: int64(time.Duration(query.RelativeTimeRange.To).Seconds()),
},
DatasourceUID: query.DatasourceUID,
Model: mdl,
ModelString: string(query.Model),
}, nil
}

View File

@@ -0,0 +1,30 @@
package hcl
import (
"fmt"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclwrite"
)
type Resource struct {
Type string `hcl:"type,label"`
Name string `hcl:"name,label"`
Body interface{} `hcl:",block"`
}
func Encode(resources ...Resource) (data []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("failed to encode struct to HCL: %v", r)
}
}()
f := hclwrite.NewEmptyFile()
for _, resource := range resources {
blk := gohcl.EncodeAsBlock(resource.Body, "resource")
blk.SetLabels([]string{resource.Type, resource.Name})
f.Body().AppendBlock(blk)
}
return f.Bytes(), nil
}

View File

@@ -0,0 +1,75 @@
package hcl
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestEncode(t *testing.T) {
type data struct {
Name string `hcl:"name"`
Number float64 `hcl:"number"`
NumberRef *float64 `hcl:"numberRef"`
Bool bool `hcl:"bul"`
BoolRef *bool `hcl:"bulRef"`
Ignored string
Blocks []data `hcl:"blocks,block"`
SubData *data `hcl:"sub,block"`
}
encoded, err := Encode(Resource{
Type: "grafana_test",
Name: "test-01",
Body: &data{
Name: "test",
Number: 123,
NumberRef: func(f float64) *float64 { return &f }(1333),
Bool: false,
BoolRef: func(f bool) *bool { return &f }(true),
Ignored: "Ignore me",
Blocks: []data{
{
Name: "el-0",
Number: 1,
},
{
Name: "el-1",
Number: 2,
Bool: true,
},
},
SubData: &data{
Name: "sub-data",
Number: 123123,
},
},
})
require.NoError(t, err)
t.Log(string(encoded))
require.Equal(t, `resource "grafana_test" "test-01" {
name = "test"
number = 123
numberRef = 1333
bul = false
bulRef = true
blocks {
name = "el-0"
number = 1
bul = false
}
blocks {
name = "el-1"
number = 2
bul = true
}
sub {
name = "sub-data"
number = 123123
bul = false
}
}
`, string(encoded))
}

View File

@@ -218,34 +218,43 @@ type AlertRuleGroup struct {
// AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1.
type AlertRuleGroupExport struct {
OrgID int64 `json:"orgId" yaml:"orgId"`
Name string `json:"name" yaml:"name"`
Folder string `json:"folder" yaml:"folder"`
Interval model.Duration `json:"interval" yaml:"interval"`
Rules []AlertRuleExport `json:"rules" yaml:"rules"`
OrgID int64 `json:"orgId" yaml:"orgId" hcl:"org_id"`
Name string `json:"name" yaml:"name" hcl:"name"`
Folder string `json:"folder" yaml:"folder"`
FolderUID string `json:"-" yaml:"-" hcl:"folder_uid"`
Interval model.Duration `json:"interval" yaml:"interval"`
IntervalSeconds int64 `json:"-" yaml:"-" hcl:"interval_seconds"`
Rules []AlertRuleExport `json:"rules" yaml:"rules" hcl:"rule,block"`
}
// AlertRuleExport is the provisioned file export of models.AlertRule.
type AlertRuleExport struct {
UID string `json:"uid" yaml:"uid"`
Title string `json:"title" yaml:"title"`
Condition string `json:"condition" yaml:"condition"`
Data []AlertQueryExport `json:"data" yaml:"data"`
Title string `json:"title" yaml:"title" hcl:"name"`
Condition string `json:"condition" yaml:"condition" hcl:"condition"`
Data []AlertQueryExport `json:"data" yaml:"data" hcl:"data,block"`
DashboardUID string `json:"dasboardUid,omitempty" yaml:"dashboardUid,omitempty"`
PanelID int64 `json:"panelId,omitempty" yaml:"panelId,omitempty"`
NoDataState NoDataState `json:"noDataState" yaml:"noDataState"`
ExecErrState ExecutionErrorState `json:"execErrState" yaml:"execErrState"`
NoDataState NoDataState `json:"noDataState" yaml:"noDataState" hcl:"no_data_state"`
ExecErrState ExecutionErrorState `json:"execErrState" yaml:"execErrState" hcl:"exec_err_state"`
For model.Duration `json:"for" yaml:"for"`
Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
IsPaused bool `json:"isPaused" yaml:"isPaused"`
ForSeconds int64 `json:"-" yaml:"-" hcl:"for"`
Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty" hcl:"annotations"`
Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty" hcl:"labels"`
IsPaused bool `json:"isPaused" yaml:"isPaused" hcl:"is_paused"`
}
// AlertQueryExport is the provisioned export of models.AlertQuery.
type AlertQueryExport struct {
RefID string `json:"refId" yaml:"refId"`
QueryType string `json:"queryType,omitempty" yaml:"queryType,omitempty"`
RelativeTimeRange RelativeTimeRange `json:"relativeTimeRange,omitempty" yaml:"relativeTimeRange,omitempty"`
DatasourceUID string `json:"datasourceUid" yaml:"datasourceUid"`
Model map[string]any `json:"model" yaml:"model"`
RefID string `json:"refId" yaml:"refId" hcl:"ref_id"`
QueryType string `json:"queryType,omitempty" yaml:"queryType,omitempty" hcl:"query_type"`
RelativeTimeRange RelativeTimeRangeExport `json:"relativeTimeRange,omitempty" yaml:"relativeTimeRange,omitempty" hcl:"relative_time_range,block"`
DatasourceUID string `json:"datasourceUid" yaml:"datasourceUid" hcl:"datasource_uid"`
Model map[string]any `json:"model" yaml:"model"`
ModelString string `json:"-" yaml:"-" hcl:"model"`
}
type RelativeTimeRangeExport struct {
FromSeconds int64 `json:"from" yaml:"from" hcl:"from"`
ToSeconds int64 `json:"to" yaml:"to" hcl:"to"`
}