Skip to content

Commit 569af5f

Browse files
authored
Fix permadiffs arising from default values in grafana_rule_group (#949)
* Fix permadiffs arising from default values in grafana_rule_group * Use standard validation lib * Remove stray debug log message. * Add test cases for model normalization.
1 parent 202828b commit 569af5f

File tree

3 files changed

+214
-4
lines changed

3 files changed

+214
-4
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
resource "grafana_folder" "rule_folder" {
2+
title = "Model Normalization Tests Folder"
3+
}
4+
5+
resource "grafana_rule_group" "rg_model_params_defaults" {
6+
name = "My Rule Group 1"
7+
folder_uid = grafana_folder.rule_folder.uid
8+
interval_seconds = 240
9+
org_id = 1
10+
rule {
11+
name = "My Alert Rule 1"
12+
for = "2m"
13+
condition = "B"
14+
no_data_state = "NoData"
15+
exec_err_state = "Alerting"
16+
annotations = {
17+
"a" = "b"
18+
"c" = "d"
19+
}
20+
labels = {
21+
"e" = "f"
22+
"g" = "h"
23+
}
24+
is_paused = false
25+
data {
26+
ref_id = "A"
27+
query_type = ""
28+
relative_time_range {
29+
from = 600
30+
to = 0
31+
}
32+
datasource_uid = "PD8C576611E62080A"
33+
model = jsonencode({
34+
hide = false
35+
intervalMs = 1000
36+
maxDataPoints = 43200
37+
refId = "A"
38+
})
39+
}
40+
}
41+
}
42+
43+
resource "grafana_rule_group" "rg_model_params_omitted" {
44+
name = "My Rule Group 2"
45+
folder_uid = grafana_folder.rule_folder.uid
46+
interval_seconds = 240
47+
org_id = 1
48+
rule {
49+
name = "My Alert Rule 2"
50+
for = "2m"
51+
condition = "B"
52+
no_data_state = "NoData"
53+
exec_err_state = "Alerting"
54+
annotations = {
55+
"a" = "b"
56+
"c" = "d"
57+
}
58+
labels = {
59+
"e" = "f"
60+
"g" = "h"
61+
}
62+
is_paused = false
63+
data {
64+
ref_id = "A"
65+
query_type = ""
66+
relative_time_range {
67+
from = 600
68+
to = 0
69+
}
70+
datasource_uid = "PD8C576611E62080A"
71+
model = jsonencode({
72+
hide = false
73+
refId = "A"
74+
})
75+
}
76+
}
77+
}
78+
79+
resource "grafana_rule_group" "rg_model_params_non_default" {
80+
name = "My Rule Group 3"
81+
folder_uid = grafana_folder.rule_folder.uid
82+
interval_seconds = 240
83+
org_id = 1
84+
rule {
85+
name = "My Alert Rule 3"
86+
for = "2m"
87+
condition = "B"
88+
no_data_state = "NoData"
89+
exec_err_state = "Alerting"
90+
annotations = {
91+
"a" = "b"
92+
"c" = "d"
93+
}
94+
labels = {
95+
"e" = "f"
96+
"g" = "h"
97+
}
98+
is_paused = false
99+
data {
100+
ref_id = "A"
101+
query_type = ""
102+
relative_time_range {
103+
from = 600
104+
to = 0
105+
}
106+
datasource_uid = "PD8C576611E62080A"
107+
model = jsonencode({
108+
hide = false
109+
intervalMs = 1001
110+
maxDataPoints = 43201
111+
refId = "A"
112+
})
113+
}
114+
}
115+
}

internal/resources/grafana/resource_alerting_rule_group.go

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/grafana/terraform-provider-grafana/internal/common"
1414
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1515
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
16+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1617
)
1718

1819
func ResourceRuleGroup() *schema.Resource {
@@ -129,9 +130,11 @@ This resource requires Grafana 9.1.0 or later.
129130
Description: "An optional identifier for the type of query being executed.",
130131
},
131132
"model": {
132-
Required: true,
133-
Type: schema.TypeString,
134-
Description: "Custom JSON data to send to the specified datasource when querying.",
133+
Required: true,
134+
Type: schema.TypeString,
135+
Description: "Custom JSON data to send to the specified datasource when querying.",
136+
ValidateFunc: validation.StringIsJSON,
137+
StateFunc: normalizeModelJSON,
135138
},
136139
"relative_time_range": {
137140
Type: schema.TypeList,
@@ -386,7 +389,7 @@ func packRuleData(queries []*gapi.AlertQuery) (interface{}, error) {
386389
timeRange["from"] = int(queries[i].RelativeTimeRange.From)
387390
timeRange["to"] = int(queries[i].RelativeTimeRange.To)
388391
data["relative_time_range"] = []interface{}{timeRange}
389-
data["model"] = string(model)
392+
data["model"] = normalizeModelJSON(string(model))
390393
result = append(result, data)
391394
}
392395
return result, nil
@@ -422,6 +425,49 @@ func unpackRuleData(raw interface{}) ([]*gapi.AlertQuery, error) {
422425
return result, nil
423426
}
424427

428+
// normalizeModelJSON is the StateFunc for the `model`. It removes well-known default
429+
// values from the model json, so that users do not see perma-diffs when not specifying
430+
// the values explicitly in their Terraform.
431+
func normalizeModelJSON(model interface{}) string {
432+
modelJSON := model.(string)
433+
var modelMap map[string]interface{}
434+
err := json.Unmarshal([]byte(modelJSON), &modelMap)
435+
if err != nil {
436+
// This should never happen if the field passes validation.
437+
log.Printf("[ERROR] Unexpected unmarshal failure for model: %v\n", err)
438+
return modelJSON
439+
}
440+
441+
// The default values taken from:
442+
// https://github.com/grafana/grafana/blob/ae688adabcfacd8bd0ac6ebaf8b78506f67962a9/pkg/services/ngalert/models/alert_query.go#L12-L13
443+
const defaultMaxDataPoints float64 = 43200
444+
const defaultIntervalMS float64 = 1000
445+
446+
// https://github.com/grafana/grafana/blob/ae688adabcfacd8bd0ac6ebaf8b78506f67962a9/pkg/services/ngalert/models/alert_query.go#L127-L134
447+
iMaxDataPoints, ok := modelMap["maxDataPoints"]
448+
if ok {
449+
maxDataPoints, ok := iMaxDataPoints.(float64)
450+
if ok && maxDataPoints == defaultMaxDataPoints {
451+
log.Printf("[DEBUG] Removing maxDataPoints from state due to being set to default value (%f)", defaultMaxDataPoints)
452+
delete(modelMap, "maxDataPoints")
453+
}
454+
}
455+
456+
// https://github.com/grafana/grafana/blob/ae688adabcfacd8bd0ac6ebaf8b78506f67962a9/pkg/services/ngalert/models/alert_query.go#L159-L166
457+
iIntervalMs, ok := modelMap["intervalMs"]
458+
if ok {
459+
intervalMs, ok := iIntervalMs.(float64)
460+
if ok && intervalMs == defaultIntervalMS {
461+
log.Printf("[DEBUG] Removing intervalMs from state due to being set to default value (%f)", defaultIntervalMS)
462+
delete(modelMap, "intervalMs")
463+
}
464+
}
465+
466+
j, _ := json.Marshal(modelMap)
467+
resultJSON := string(j)
468+
return resultJSON
469+
}
470+
425471
func unpackMap(raw interface{}) map[string]string {
426472
json := raw.(map[string]interface{})
427473
result := map[string]string{}

internal/resources/grafana/resource_alerting_rule_group_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func TestAccAlertRule_basic(t *testing.T) {
3333
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "interval_seconds", "240"),
3434
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "org_id", "1"),
3535
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.#", "1"),
36+
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"),
3637
),
3738
},
3839
// Test import.
@@ -53,6 +54,7 @@ func TestAccAlertRule_basic(t *testing.T) {
5354
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.#", "1"),
5455
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.name", "A Different Rule"),
5556
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.for", "2m"),
57+
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"),
5658
),
5759
},
5860
// Test rename group.
@@ -67,6 +69,7 @@ func TestAccAlertRule_basic(t *testing.T) {
6769
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.#", "1"),
6870
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.name", "My Alert Rule 1"),
6971
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.for", "2m"),
72+
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"),
7073
),
7174
},
7275
// Test change interval.
@@ -92,12 +95,58 @@ func TestAccAlertRule_basic(t *testing.T) {
9295
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.#", "1"),
9396
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.name", "My Alert Rule 1"),
9497
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.for", "2m"),
98+
resource.TestCheckResourceAttr("grafana_rule_group.my_alert_rule", "rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"),
9599
),
96100
},
97101
},
98102
})
99103
}
100104

105+
func TestAccAlertRule_model(t *testing.T) {
106+
testutils.CheckOSSTestsEnabled(t)
107+
testutils.CheckOSSTestsSemver(t, ">=9.1.0")
108+
109+
var group gapi.RuleGroup
110+
111+
resource.ParallelTest(t, resource.TestCase{
112+
ProviderFactories: testutils.ProviderFactories,
113+
// Implicitly tests deletion.
114+
CheckDestroy: testAlertRuleCheckDestroy(&group),
115+
Steps: []resource.TestStep{
116+
// Test creation.
117+
{
118+
Config: testutils.TestAccExample(t, "resources/grafana_rule_group/_acc_model_normalization.tf"),
119+
Check: resource.ComposeTestCheckFunc(
120+
// Model normalization means that default values for fields in the model JSON are not
121+
// included in the state, to prevent permadiffs, but non-default values must be included.
122+
resource.TestCheckResourceAttr("grafana_rule_group.rg_model_params_defaults",
123+
"rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"),
124+
resource.TestCheckResourceAttr("grafana_rule_group.rg_model_params_omitted",
125+
"rule.0.data.0.model", "{\"hide\":false,\"refId\":\"A\"}"),
126+
resource.TestCheckResourceAttr("grafana_rule_group.rg_model_params_non_default",
127+
"rule.0.data.0.model", "{\"hide\":false,\"intervalMs\":1001,\"maxDataPoints\":43201,\"refId\":\"A\"}"),
128+
),
129+
},
130+
// Test import.
131+
{
132+
ResourceName: "grafana_rule_group.rg_model_params_defaults",
133+
ImportState: true,
134+
ImportStateVerify: true,
135+
},
136+
{
137+
ResourceName: "grafana_rule_group.rg_model_params_omitted",
138+
ImportState: true,
139+
ImportStateVerify: true,
140+
},
141+
{
142+
ResourceName: "grafana_rule_group.rg_model_params_non_default",
143+
ImportState: true,
144+
ImportStateVerify: true,
145+
},
146+
},
147+
})
148+
}
149+
101150
func TestAccAlertRule_compound(t *testing.T) {
102151
testutils.CheckOSSTestsEnabled(t)
103152
testutils.CheckOSSTestsSemver(t, ">=9.1.0")

0 commit comments

Comments
 (0)