Skip to content

Commit af2a57e

Browse files
authored
fix(oncall): make integration labels idempotent (#2536)
* fix(oncall): make integration labels idempotent The `labels` and `dynamic_labels` attributes caused spurious diffs on every plan due to two issues: 1. flattenLabels() injected a synthetic "id" field (set to the label key name) that is never present in user configuration. 2. TypeList compares elements positionally, but the API may return labels in a different order than the config, causing labels to appear swapped or removed. Fix by adding a DiffSuppressFunc that: - Suppresses the synthetic "id" and map-size diffs from old state - Compares labels as a set of {key, value} pairs, ignoring order Also remove the synthetic "id" from flattenLabels() so new state no longer includes it. * fix(oncall): mark legacy id suppression for removal in next major version
1 parent 20f5ad4 commit af2a57e

File tree

2 files changed

+195
-6
lines changed

2 files changed

+195
-6
lines changed

internal/resources/oncall/resource_integration.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,9 @@ func resourceIntegration() *common.Resource {
237237
Type: schema.TypeString,
238238
},
239239
},
240-
Optional: true,
241-
Description: "A list of string-to-string mappings for static labels. Each map must include one key named \"key\" and one key named \"value\" (using the `grafana_oncall_label` datasource).",
240+
Optional: true,
241+
DiffSuppressFunc: labelsDiffSuppress,
242+
Description: "A list of string-to-string mappings for static labels. Each map must include one key named \"key\" and one key named \"value\" (using the `grafana_oncall_label` datasource).",
242243
},
243244
"dynamic_labels": {
244245
Type: schema.TypeList,
@@ -248,8 +249,9 @@ func resourceIntegration() *common.Resource {
248249
Type: schema.TypeString,
249250
},
250251
},
251-
Optional: true,
252-
Description: "A list of string-to-string mappings for dynamic labels. Each map must include one key named \"key\" and one key named \"value\" (using the `grafana_oncall_label` datasource).",
252+
Optional: true,
253+
DiffSuppressFunc: labelsDiffSuppress,
254+
Description: "A list of string-to-string mappings for dynamic labels. Each map must include one key named \"key\" and one key named \"value\" (using the `grafana_oncall_label` datasource).",
253255
},
254256
},
255257
}
@@ -795,6 +797,9 @@ func expandDefaultRoute(input []any) *onCallAPI.DefaultRoute {
795797
defaultRoute := onCallAPI.DefaultRoute{}
796798

797799
for _, r := range input {
800+
if r == nil {
801+
continue
802+
}
798803
inputMap := r.(map[string]any)
799804
id := inputMap["id"].(string)
800805
defaultRoute.ID = id
@@ -825,6 +830,9 @@ func expandLabels(input []any) []*onCallAPI.Label {
825830
labelsData := make([]*onCallAPI.Label, 0, 1)
826831

827832
for _, r := range input {
833+
if r == nil {
834+
continue
835+
}
828836
inputMap := r.(map[string]any)
829837
key, keyExists := inputMap["key"]
830838
value, valueExists := inputMap["value"]
@@ -841,15 +849,65 @@ func expandLabels(input []any) []*onCallAPI.Label {
841849
}
842850

843851
func flattenLabels(labels []*onCallAPI.Label) []map[string]string {
844-
flattenedLabels := make([]map[string]string, 0, 1)
852+
flattenedLabels := make([]map[string]string, 0, len(labels))
845853

846854
for _, l := range labels {
847855
flattenedLabels = append(flattenedLabels, map[string]string{
848-
"id": l.Key.Name,
849856
"key": l.Key.Name,
850857
"value": l.Value.Name,
851858
})
852859
}
853860

854861
return flattenedLabels
855862
}
863+
864+
// labelsDiffSuppress suppresses spurious diffs on the labels and dynamic_labels attributes.
865+
// It handles two cases:
866+
// 1. The synthetic "id" field that flattenLabels previously stored in state.
867+
// 2. Label ordering: the API may return labels in a different order than the config, but TypeList
868+
// compares positionally. We suppress the diff when the set of {key,value} pairs is identical.
869+
func labelsDiffSuppress(k, old, new string, d *schema.ResourceData) bool {
870+
// TODO: remove in next major version.
871+
// Suppress the synthetic "id" field that was previously stored in state by
872+
// the old flattenLabels. Users upgrading still have "id" in their state and
873+
// would see a spurious diff showing its removal without this.
874+
if strings.HasSuffix(k, ".id") {
875+
return true
876+
}
877+
878+
// For count, map-size, key, and value diffs: suppress if the set of {key, value} pairs is identical.
879+
// This also handles the map-size diff caused by the legacy "id" field (3 keys → 2 keys).
880+
attr := "labels"
881+
if strings.HasPrefix(k, "dynamic_labels") {
882+
attr = "dynamic_labels"
883+
}
884+
885+
oldRaw, newRaw := d.GetChange(attr)
886+
return labelsSetEqual(oldRaw.([]any), newRaw.([]any))
887+
}
888+
889+
// labelsSetEqual compares two label lists as sets of {key, value} pairs, ignoring order and the "id" field.
890+
func labelsSetEqual(a, b []any) bool {
891+
if len(a) != len(b) {
892+
return false
893+
}
894+
895+
setA := make(map[string]string, len(a))
896+
for _, item := range a {
897+
m := item.(map[string]any)
898+
key, _ := m["key"].(string)
899+
value, _ := m["value"].(string)
900+
setA[key] = value
901+
}
902+
903+
for _, item := range b {
904+
m := item.(map[string]any)
905+
key, _ := m["key"].(string)
906+
value, _ := m["value"].(string)
907+
if setA[key] != value {
908+
return false
909+
}
910+
}
911+
912+
return true
913+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package oncall
2+
3+
import (
4+
"testing"
5+
6+
onCallAPI "github.com/grafana/amixr-api-go-client"
7+
)
8+
9+
func TestFlattenLabels_NoIDField(t *testing.T) {
10+
labels := []*onCallAPI.Label{
11+
{Key: onCallAPI.KeyValueName{Name: "severity"}, Value: onCallAPI.KeyValueName{Name: "critical"}},
12+
}
13+
14+
result := flattenLabels(labels)
15+
16+
if len(result) != 1 {
17+
t.Fatalf("expected 1 label, got %d", len(result))
18+
}
19+
if _, exists := result[0]["id"]; exists {
20+
t.Error("flattenLabels should not include an 'id' field; it causes spurious diffs because the config never specifies it")
21+
}
22+
if result[0]["key"] != "severity" {
23+
t.Errorf("expected key 'severity', got %q", result[0]["key"])
24+
}
25+
if result[0]["value"] != "critical" {
26+
t.Errorf("expected value 'critical', got %q", result[0]["value"])
27+
}
28+
}
29+
30+
func TestFlattenLabels_Empty(t *testing.T) {
31+
result := flattenLabels(nil)
32+
if len(result) != 0 {
33+
t.Fatalf("expected 0 labels, got %d", len(result))
34+
}
35+
}
36+
37+
func TestExpandLabels_NilElement(t *testing.T) {
38+
input := []any{
39+
nil,
40+
map[string]any{"key": "severity", "value": "critical"},
41+
nil,
42+
}
43+
44+
result := expandLabels(input)
45+
46+
if len(result) != 1 {
47+
t.Fatalf("expected 1 label, got %d", len(result))
48+
}
49+
if result[0].Key.Name != "severity" {
50+
t.Errorf("expected key 'severity', got %q", result[0].Key.Name)
51+
}
52+
if result[0].Value.Name != "critical" {
53+
t.Errorf("expected value 'critical', got %q", result[0].Value.Name)
54+
}
55+
}
56+
57+
func TestExpandLabels_AllNil(t *testing.T) {
58+
input := []any{nil, nil}
59+
60+
result := expandLabels(input)
61+
62+
if len(result) != 0 {
63+
t.Fatalf("expected 0 labels, got %d", len(result))
64+
}
65+
}
66+
67+
func TestLabelsSetEqual_SameOrder(t *testing.T) {
68+
a := []any{
69+
map[string]any{"key": "severity", "value": "critical"},
70+
map[string]any{"key": "team", "value": "platform"},
71+
}
72+
b := []any{
73+
map[string]any{"key": "severity", "value": "critical"},
74+
map[string]any{"key": "team", "value": "platform"},
75+
}
76+
if !labelsSetEqual(a, b) {
77+
t.Error("expected labels with same content and order to be equal")
78+
}
79+
}
80+
81+
func TestLabelsSetEqual_DifferentOrder(t *testing.T) {
82+
a := []any{
83+
map[string]any{"key": "team", "value": "platform"},
84+
map[string]any{"key": "severity", "value": "critical"},
85+
}
86+
b := []any{
87+
map[string]any{"key": "severity", "value": "critical"},
88+
map[string]any{"key": "team", "value": "platform"},
89+
}
90+
if !labelsSetEqual(a, b) {
91+
t.Error("expected labels with same content in different order to be equal")
92+
}
93+
}
94+
95+
func TestLabelsSetEqual_IgnoresIDField(t *testing.T) {
96+
// Old state has "id" field, new config does not
97+
a := []any{
98+
map[string]any{"id": "severity", "key": "severity", "value": "critical"},
99+
}
100+
b := []any{
101+
map[string]any{"key": "severity", "value": "critical"},
102+
}
103+
if !labelsSetEqual(a, b) {
104+
t.Error("expected labels to be equal even when old state has 'id' field")
105+
}
106+
}
107+
108+
func TestLabelsSetEqual_DifferentValues(t *testing.T) {
109+
a := []any{
110+
map[string]any{"key": "severity", "value": "critical"},
111+
}
112+
b := []any{
113+
map[string]any{"key": "severity", "value": "warning"},
114+
}
115+
if labelsSetEqual(a, b) {
116+
t.Error("expected labels with different values to NOT be equal")
117+
}
118+
}
119+
120+
func TestLabelsSetEqual_DifferentCount(t *testing.T) {
121+
a := []any{
122+
map[string]any{"key": "severity", "value": "critical"},
123+
}
124+
b := []any{
125+
map[string]any{"key": "severity", "value": "critical"},
126+
map[string]any{"key": "team", "value": "platform"},
127+
}
128+
if labelsSetEqual(a, b) {
129+
t.Error("expected labels with different count to NOT be equal")
130+
}
131+
}

0 commit comments

Comments
 (0)