diff --git a/docs/resources/rule_group.md b/docs/resources/rule_group.md index e0bc0f1a4..399984a43 100644 --- a/docs/resources/rule_group.md +++ b/docs/resources/rule_group.md @@ -142,7 +142,9 @@ Optional: - `exec_err_state` (String) Describes what state to enter when the rule's query is invalid and the rule cannot be executed. Options are OK, Error, KeepLast, and Alerting. Defaults to Alerting if not set. - `for` (String) The amount of time for which the rule must be breached for the rule to be considered to be Firing. Before this time has elapsed, the rule is only considered to be Pending. Defaults to `0`. - `is_paused` (Boolean) Sets whether the alert should be paused or not. Defaults to `false`. +- `keep_firing_for` (String) The amount of time for which the rule will considered to be Recovering after initially Firing. Before this time has elapsed, the rule will continue to fire once it's been triggered. - `labels` (Map of String) Key-value pairs to attach to the alert rule that can be used in matching, grouping, and routing. Defaults to `map[]`. +- `missing_series_evals_to_resolve` (Number) The number of missing series evaluations that must occur before the rule is considered to be resolved. - `no_data_state` (String) Describes what state to enter when the rule's query returns No Data. Options are OK, NoData, KeepLast, and Alerting. Defaults to NoData if not set. - `notification_settings` (Block List, Max: 1) Notification settings for the rule. If specified, it overrides the notification policies. Available since Grafana 10.4, requires feature flag 'alertingSimplifiedRouting' to be enabled. (see [below for nested schema](#nestedblock--rule--notification_settings)) - `record` (Block List, Max: 1) Settings for a recording rule. Available since Grafana 11.2, requires feature flag 'grafanaManagedRecordingRules' to be enabled. (see [below for nested schema](#nestedblock--rule--record)) diff --git a/go.mod b/go.mod index 2ffb55863..52ab784ff 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/grafana/fleet-management-api v1.0.0 github.com/grafana/grafana-app-sdk v0.35.2-0.20250408075831-c2a87bde0849 github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20250214150112-a52892176c26 - github.com/grafana/grafana-openapi-client-go v0.0.0-20241113095943-9cb2bbfeb8a3 + github.com/grafana/grafana-openapi-client-go v0.0.0-20250424142317-beadd3136e10 github.com/grafana/grafana/apps/dashboard v0.0.0-20250424064802-2fbb2d6f5d27 github.com/grafana/grafana/apps/playlist v0.0.0-20250424064802-2fbb2d6f5d27 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250424064802-2fbb2d6f5d27 @@ -83,12 +83,12 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/errors v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -126,7 +126,7 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/magefile/mage v1.15.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 517790885..3e3be5ece 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU= +github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -120,8 +120,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= @@ -180,8 +180,8 @@ github.com/grafana/grafana-app-sdk/logging v0.35.1 h1:taVpl+RoixTYl0JBJGhH+fPVmw github.com/grafana/grafana-app-sdk/logging v0.35.1/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20250214150112-a52892176c26 h1:7NMB6/x0CcfH/zKQ5D+3Ffb2DbYMJBx0QdJ1GGdw8z4= github.com/grafana/grafana-com-public-clients/go/gcom v0.0.0-20250214150112-a52892176c26/go.mod h1:sYWkB3NhyirQJfy3wtNQ29UYjoHbRlJlYhqN1jNsC5g= -github.com/grafana/grafana-openapi-client-go v0.0.0-20241113095943-9cb2bbfeb8a3 h1:poKxGlUaEYVp2DMofC/I2GHw/vvtHAZ20c48I8rFB6M= -github.com/grafana/grafana-openapi-client-go v0.0.0-20241113095943-9cb2bbfeb8a3/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= +github.com/grafana/grafana-openapi-client-go v0.0.0-20250424142317-beadd3136e10 h1:RznghhbjMUEvGJD0p9AOCjnVOTq0MiINDt98BscNcdw= +github.com/grafana/grafana-openapi-client-go v0.0.0-20250424142317-beadd3136e10/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= github.com/grafana/grafana-plugin-sdk-go v0.275.0 h1:icGmZG91lVqIo79w/pSki6N44d3IjOjTfsfQPfu4THU= github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= github.com/grafana/grafana/apps/dashboard v0.0.0-20250424064802-2fbb2d6f5d27 h1:UAv+x7lwteJR4pPMpIZ9hzngzAT1oiOQ55pq2GS4YCE= @@ -305,8 +305,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= diff --git a/internal/resources/grafana/resource_alerting_rule_group.go b/internal/resources/grafana/resource_alerting_rule_group.go index 31085197f..d336c22dc 100644 --- a/internal/resources/grafana/resource_alerting_rule_group.go +++ b/internal/resources/grafana/resource_alerting_rule_group.go @@ -15,6 +15,7 @@ import ( goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana-openapi-client-go/client/provisioning" "github.com/grafana/grafana-openapi-client-go/models" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -104,6 +105,28 @@ This resource requires Grafana 9.1.0 or later. return oldDuration == newDuration }, }, + "keep_firing_for": { + Type: schema.TypeString, + Optional: true, + Description: "The amount of time for which the rule will considered to be Recovering after initially Firing. Before this time has elapsed, the rule will continue to fire once it's been triggered.", + ValidateDiagFunc: common.ValidateDurationWithDays, + DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { + oldDuration, _ := strfmt.ParseDuration(oldValue) + newDuration, _ := strfmt.ParseDuration(newValue) + return oldDuration == newDuration + }, + }, + "missing_series_evals_to_resolve": { + Type: schema.TypeInt, + Optional: true, + Description: "The number of missing series evaluations that must occur before the rule is considered to be resolved.", + ValidateDiagFunc: func(i any, path cty.Path) (diags diag.Diagnostics) { + if i != nil && i.(int) < 1 { + return diag.Errorf("missing_series_evals_to_resolve must be greater than or equal to 1") + } + return nil + }, + }, "no_data_state": { Type: schema.TypeString, Optional: true, @@ -519,6 +542,14 @@ func packAlertRule(r *models.ProvisionedAlertRule) (interface{}, error) { json["record"] = record } + if r.KeepFiringFor != 0 { + json["keep_firing_for"] = r.KeepFiringFor.String() + } + + if r.MissingSeriesEvalsToResolve >= 1 { + json["missing_series_evals_to_resolve"] = r.MissingSeriesEvalsToResolve + } + return json, nil } @@ -538,11 +569,28 @@ func unpackAlertRule(raw interface{}, groupName string, folderUID string, orgID return nil, err } + keepFiringForStr := json["keep_firing_for"].(string) + if keepFiringForStr == "" { + keepFiringForStr = "0" + } + keepFiringForDuration, err := strfmt.ParseDuration(keepFiringForStr) + if err != nil { + return nil, err + } + ns, err := unpackNotificationSettings(json["notification_settings"]) if err != nil { return nil, err } + var missingSeriesEvalsToResolve int64 + if val, ok := json["missing_series_evals_to_resolve"]; ok && val != nil { + intVal := val.(int) + if intVal >= 1 { + missingSeriesEvalsToResolve = int64(intVal) + } + } + // Check for conflicting fields before unpacking the rest of the rule. // This is a workaround due to the lack of support for ConflictsWith in Lists in the SDK. errState := json["exec_err_state"].(string) @@ -555,6 +603,12 @@ func unpackAlertRule(raw interface{}, groupName string, folderUID string, orgID if forDuration != 0 { return nil, fmt.Errorf(incompatFieldMsgFmt, "for") } + if keepFiringForDuration != 0 { + return nil, fmt.Errorf(incompatFieldMsgFmt, "keep_firing_for") + } + if missingSeriesEvalsToResolve != 0 { + return nil, fmt.Errorf(incompatFieldMsgFmt, "missing_series_evals_to_resolve") + } if noDataState != "" { return nil, fmt.Errorf(incompatFieldMsgFmt, "no_data_state") } @@ -580,21 +634,23 @@ func unpackAlertRule(raw interface{}, groupName string, folderUID string, orgID } rule := models.ProvisionedAlertRule{ - UID: json["uid"].(string), - Title: common.Ref(json["name"].(string)), - FolderUID: common.Ref(folderUID), - RuleGroup: common.Ref(groupName), - OrgID: common.Ref(orgID), - ExecErrState: common.Ref(errState), - NoDataState: common.Ref(noDataState), - For: common.Ref(strfmt.Duration(forDuration)), - Data: data, - Condition: common.Ref(condition), - Labels: unpackMap(json["labels"]), - Annotations: unpackMap(json["annotations"]), - IsPaused: json["is_paused"].(bool), - NotificationSettings: ns, - Record: unpackRecord(json["record"]), + UID: json["uid"].(string), + Title: common.Ref(json["name"].(string)), + FolderUID: common.Ref(folderUID), + RuleGroup: common.Ref(groupName), + OrgID: common.Ref(orgID), + ExecErrState: common.Ref(errState), + NoDataState: common.Ref(noDataState), + For: common.Ref(strfmt.Duration(forDuration)), + KeepFiringFor: strfmt.Duration(keepFiringForDuration), + Data: data, + Condition: common.Ref(condition), + Labels: unpackMap(json["labels"]), + Annotations: unpackMap(json["annotations"]), + IsPaused: json["is_paused"].(bool), + NotificationSettings: ns, + Record: unpackRecord(json["record"]), + MissingSeriesEvalsToResolve: missingSeriesEvalsToResolve, } return &rule, nil diff --git a/internal/resources/grafana/resource_alerting_rule_group_test.go b/internal/resources/grafana/resource_alerting_rule_group_test.go index 448571fa4..7b210f4e4 100644 --- a/internal/resources/grafana/resource_alerting_rule_group_test.go +++ b/internal/resources/grafana/resource_alerting_rule_group_test.go @@ -723,6 +723,47 @@ func TestAccAlertRule_zeroSeconds(t *testing.T) { }) } +func TestAccAlertRule_keepFiringFor(t *testing.T) { + testutils.CheckOSSTestsEnabled(t, ">=11.6.0") + + var group models.AlertRuleGroup + var name = acctest.RandString(10) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: alertingRuleGroupCheckExists.destroyed(&group, nil), + Steps: []resource.TestStep{ + { + Config: testAccAlertRuleKeepFiringFor(name, "5m"), + Check: resource.ComposeTestCheckFunc( + alertingRuleGroupCheckExists.exists("grafana_rule_group.my_rule_group", &group), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "name", name), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "rule.#", "1"), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "rule.0.name", "My Keep Firing Test"), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "rule.0.keep_firing_for", "5m0s"), + ), + }, + // Test updating the keep_firing_for value + { + Config: testAccAlertRuleKeepFiringFor(name, "10m"), + Check: resource.ComposeTestCheckFunc( + alertingRuleGroupCheckExists.exists("grafana_rule_group.my_rule_group", &group), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "name", name), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "rule.#", "1"), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "rule.0.name", "My Keep Firing Test"), + resource.TestCheckResourceAttr("grafana_rule_group.my_rule_group", "rule.0.keep_firing_for", "10m0s"), + ), + }, + // Test import + { + ResourceName: "grafana_rule_group.my_rule_group", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func TestAccAlertRule_NotificationSettings(t *testing.T) { testutils.CheckOSSTestsEnabled(t, ">=10.4.0") @@ -1044,3 +1085,136 @@ resource "grafana_rule_group" "my_rule_group" { } }`, name, metric, refID) } + +func testAccAlertRuleKeepFiringFor(name string, keepFiringFor string) string { + return fmt.Sprintf(` +resource "grafana_folder" "rule_folder" { + title = "%[1]s" +} + +resource "grafana_data_source" "testdata_datasource" { + name = "%[1]s" + type = "grafana-testdata-datasource" + url = "http://localhost:3333" +} + +resource "grafana_rule_group" "my_rule_group" { + name = "%[1]s" + folder_uid = grafana_folder.rule_folder.uid + interval_seconds = 60 + org_id = 1 + + rule { + name = "My Keep Firing Test" + for = "1m" + keep_firing_for = "%[2]s" + condition = "C" + no_data_state = "NoData" + exec_err_state = "Alerting" + is_paused = false + + // Query the datasource. + data { + ref_id = "A" + relative_time_range { + from = 600 + to = 0 + } + datasource_uid = grafana_data_source.testdata_datasource.uid + model = jsonencode({ + intervalMs = 1000 + maxDataPoints = 43200 + refId = "A" + }) + } + + data { + ref_id = "B" + query_type = "" + relative_time_range { + from = 0 + to = 0 + } + datasource_uid = "-100" + model = jsonencode({ + conditions = [ + { + evaluator = { + params = [ + 3 + ], + type = "gt" + }, + operator = { + type = "and" + }, + query = { + params = [ + "A" + ] + }, + reducer = { + params = [], + type = "last" + }, + type = "query" + } + ], + datasource = { + type = "__expr__", + uid = "-100" + }, + hide = false, + intervalMs = 1000, + maxDataPoints = 43200, + refId = "B", + type = "classic_conditions" + }) + } + + data { + ref_id = "C" + query_type = "" + relative_time_range { + from = 0 + to = 0 + } + datasource_uid = "-100" + model = jsonencode({ + conditions = [ + { + evaluator = { + params = [ + 0, 0 + ], + type = "within_range" + }, + operator = { + type = "and" + }, + query = { + params = [ + "B" + ] + }, + reducer = { + params = [], + type = "last" + }, + type = "query" + } + ], + datasource = { + type = "__expr__", + uid = "-100" + }, + hide = false, + intervalMs = 1000, + maxDataPoints = 43200, + refId = "C", + type = "classic_conditions" + }) + } + } +}`, name, keepFiringFor) +} diff --git a/internal/resources/grafana/resource_team.go b/internal/resources/grafana/resource_team.go index e35e302b7..b31df1ece 100644 --- a/internal/resources/grafana/resource_team.go +++ b/internal/resources/grafana/resource_team.go @@ -427,7 +427,7 @@ func memberChanges(stateMembers, configMembers map[string]TeamMember) []MemberCh func addMemberIdsToChanges(client *goapi.GrafanaHTTPAPI, changes []MemberChange) ([]MemberChange, error) { gUserMap := make(map[string]int64) - resp, err := client.Org.GetOrgUsersForCurrentOrg() + resp, err := client.Org.GetOrgUsersForCurrentOrg(nil) if err != nil { return nil, err }