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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 308 additions and 34 deletions

4
go.mod
View File

@ -68,6 +68,7 @@ require (
github.com/hashicorp/go-hclog v1.5.0 // @grafana/plugins-platform-backend
github.com/hashicorp/go-plugin v1.4.9 // @grafana/plugins-platform-backend
github.com/hashicorp/go-version v1.6.0 // @grafana/backend-platform
github.com/hashicorp/hcl/v2 v2.17.0 // @grafana/alerting-squad-backend
github.com/influxdata/influxdb-client-go/v2 v2.12.3 // @grafana/observability-metrics
github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 // @grafana/grafana-app-platform-squad
github.com/jmespath/go-jmespath v0.4.0 // @grafana/backend-platform
@ -290,10 +291,12 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/agext/levenshtein v1.2.1 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect
github.com/apache/thrift v0.18.1 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/bmatcuk/doublestar v1.1.1 // indirect
github.com/buildkite/yaml v2.1.0+incompatible // indirect
@ -391,6 +394,7 @@ require (
github.com/weaveworks/promrus v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
github.com/yuin/gopher-lua v1.1.0 // indirect
github.com/zclconf/go-cty v1.13.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/etcd/api/v3 v3.5.7 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect

8
go.sum
View File

@ -672,6 +672,8 @@ github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3H
github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
@ -711,6 +713,8 @@ github.com/apache/thrift v0.18.1 h1:lNhK/1nqjbwbiOPDBPFJVKxgDEGSepKuTh6OLiXW8kg=
github.com/apache/thrift v0.18.1/go.mod h1:rdQn/dCcDKEWjjylUeueum4vQEjG2v8v2PqriUnbr+I=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@ -1904,6 +1908,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.2 h1:Dwmkdr5Nc/oBiXgJS3CDHNhJtIHkuZ3DZF5
github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.17.0 h1:z1XvSUyXd1HP10U4lrLg5e0JMVz6CPaJvAgxM0KNZVY=
github.com/hashicorp/hcl/v2 v2.17.0/go.mod h1:gJyW2PTShkJqQBKpAmPO3yxMxIuoXkOF2TpqXzrQyx4=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
@ -2906,6 +2912,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0=
github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=

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"`
}

View File

@ -40,7 +40,7 @@ export interface Datasource {
export const PREVIEW_URL = '/api/v1/rule/test/grafana';
export const PROM_RULES_URL = 'api/prometheus/grafana/api/v1/rules';
function getProvisioningUrl(ruleUid: string, format: 'yaml' | 'json' = 'yaml') {
function getProvisioningUrl(ruleUid: string, format: RuleExportFormats = 'yaml') {
return `/api/v1/provisioning/alert-rules/${ruleUid}/export?format=${format}`;
}

View File

@ -21,16 +21,15 @@ const YamlRuleExportProvider: RuleExportProvider<'yaml'> = {
exportFormat: 'yaml',
};
// TODO Waiting for BE changes
// const HclRuleExportProvider: RuleExportProvider<'hcl'> = {
// name: 'HCL',
// exportFormat: 'hcl',
// };
const HclRuleExportProvider: RuleExportProvider<'hcl'> = {
name: 'Terraform (HCL)',
exportFormat: 'hcl',
};
export const grafanaRuleExportProviders = {
[JsonRuleExportProvider.exportFormat]: JsonRuleExportProvider,
[YamlRuleExportProvider.exportFormat]: YamlRuleExportProvider,
// [HclRuleExportProvider.exportFormat]: HclRuleExportProvider,
[HclRuleExportProvider.exportFormat]: HclRuleExportProvider,
} as const;
export type RuleExportFormats = keyof typeof grafanaRuleExportProviders;