Skip to content

Commit 6de4ff0

Browse files
helper/schema: Schema.DiffSuppressOnRefresh (#882)
Unfortunately in the original design of DiffSuppressFunc we made it work only during planning, and so the normalization vs. drift classification it does isn't honored in any other situation. Unilaterally enabling it for both refresh and apply would be too risky at this late stage where so many existing provider behaviors rely on these subtle details, but here we introduce a more surgical _opt-in_ mechanism whereby specific attributes can be set to also check the Refresh result against DiffSuppressFunc, and so avoid reporting any normalization done by the remote system as if it were drift. This behavior will be primarily useful for strings containing complex data serialized into some particular microsyntax which allows expressing the same information multiple ways. In particular, existing situations where we classify whitespace changes and other such immaterial changes in JSON values as normalization vs. drift would be a good candidate to enable this flag, so that we can get the same results during refreshing and thus avoid churning any downstream resources that refer to the unexpectedly-updated result.
1 parent db56379 commit 6de4ff0

File tree

5 files changed

+381
-0
lines changed

5 files changed

+381
-0
lines changed

.changelog/882.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```release-note:note
2+
helper/schema: The `Schema` type `DiffSuppressOnRefresh` field opts in to using `DiffSuppressFunc` to detect normalization changes during refresh, using the same rules as for planning. This can prevent normalization cascading downstream and producing confusing changes in other resources, and will avoid reporting "Values changed outside of Terraform" for normalization-only situations. This is a desirable behavior for most attributes that have `DiffSuppressFunc` and so would ideally be on by default, but it is opt-in for backward compatibility reasons.
3+
```
4+
5+
```release-note:enhancement
6+
helper/schema: Added the `DiffSuppressOnRefresh` field to the `Schema` type
7+
```

helper/schema/resource.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,7 @@ func (r *Resource) RefreshWithoutUpgrade(
655655
state = nil
656656
}
657657

658+
schemaMap(r.Schema).handleDiffSuppressOnRefresh(ctx, s, state)
658659
return r.recordCurrentSchemaVersion(state), diags
659660
}
660661

helper/schema/resource_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,53 @@ func TestResourceRefresh(t *testing.T) {
11431143
}
11441144
}
11451145

1146+
func TestResourceRefresh_DiffSuppressOnRefresh(t *testing.T) {
1147+
r := &Resource{
1148+
SchemaVersion: 2,
1149+
Schema: map[string]*Schema{
1150+
"foo": {
1151+
Type: TypeString,
1152+
Optional: true,
1153+
DiffSuppressFunc: func(key, oldV, newV string, d *ResourceData) bool {
1154+
return true
1155+
},
1156+
DiffSuppressOnRefresh: true,
1157+
},
1158+
},
1159+
}
1160+
1161+
r.Read = func(d *ResourceData, m interface{}) error {
1162+
return d.Set("foo", "howdy")
1163+
}
1164+
1165+
s := &terraform.InstanceState{
1166+
ID: "bar",
1167+
Attributes: map[string]string{
1168+
"foo": "hello",
1169+
},
1170+
}
1171+
1172+
expected := &terraform.InstanceState{
1173+
ID: "bar",
1174+
Attributes: map[string]string{
1175+
"id": "bar",
1176+
"foo": "hello", // new value was suppressed
1177+
},
1178+
Meta: map[string]interface{}{
1179+
"schema_version": "2",
1180+
},
1181+
}
1182+
1183+
actual, diags := r.RefreshWithoutUpgrade(context.Background(), s, 42)
1184+
if diags.HasError() {
1185+
t.Fatalf("err: %s", diagutils.ErrorDiags(diags))
1186+
}
1187+
1188+
if !reflect.DeepEqual(actual, expected) {
1189+
t.Fatalf("bad: %#v", actual)
1190+
}
1191+
}
1192+
11461193
func TestResourceRefresh_blankId(t *testing.T) {
11471194
r := &Resource{
11481195
Schema: map[string]*Schema{

helper/schema/schema.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"strings"
2424

2525
"github.com/hashicorp/go-cty/cty"
26+
"github.com/hashicorp/terraform-plugin-log/tfsdklog"
2627
"github.com/mitchellh/copystructure"
2728
"github.com/mitchellh/mapstructure"
2829

@@ -92,8 +93,42 @@ type Schema struct {
9293
// If CustomizeDiffFunc makes this field ForceNew=true, the
9394
// following DiffSuppressFunc will come in with the value of old being
9495
// empty, as if creating a new resource.
96+
//
97+
// By default, DiffSuppressFunc is considered only when deciding whether
98+
// a configuration value is significantly different than the prior state
99+
// value during planning. Set DiffSuppressOnRefresh to opt in to checking
100+
// this also during the refresh step.
95101
DiffSuppressFunc SchemaDiffSuppressFunc
96102

103+
// DiffSuppressOnRefresh enables using the DiffSuppressFunc to ignore
104+
// normalization-classified changes returned by the resource type's
105+
// "Read" or "ReadContext" function, in addition to the default behavior of
106+
// doing so during planning.
107+
//
108+
// This is a particularly good choice for attributes which take strings
109+
// containing "microsyntaxes" where various different values are packed
110+
// together in some serialization where there are many ways to express the
111+
// same information. For example, attributes which accept JSON data can
112+
// include different whitespace characters without changing meaning, and
113+
// case-insensitive identifiers may refer to the same object using different
114+
// characters.
115+
//
116+
// This is valid only for attributes of primitive types, because
117+
// DiffSuppressFunc itself is only compatible with primitive types.
118+
//
119+
// The key benefit of activating this flag is that the result of Read or
120+
// ReadContext will be cleaned of normalization-only changes in the same
121+
// way as the planning result would normaly be, which therefore prevents
122+
// churn for downstream expressions deriving from this attribute and
123+
// prevents incorrect "Values changed outside of Terraform" messages
124+
// when the remote API returns values which have the same meaning as the
125+
// prior state but in a different serialization.
126+
//
127+
// This is an opt-in because it was a later addition to the DiffSuppressFunc
128+
// functionality which would cause some significant changes in behavior
129+
// for existing providers if activated everywhere all at once.
130+
DiffSuppressOnRefresh bool
131+
97132
// If this is non-nil, then this will be a default value that is used
98133
// when this item is not set in the configuration.
99134
//
@@ -767,6 +802,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro
767802
}
768803
}
769804

805+
if v.DiffSuppressOnRefresh && v.DiffSuppressFunc == nil {
806+
return fmt.Errorf("%s: cannot set DiffSuppressOnRefresh without DiffSuppressFunc", k)
807+
}
808+
770809
if v.Type == TypeList || v.Type == TypeSet {
771810
if v.Elem == nil {
772811
return fmt.Errorf("%s: Elem must be set for lists", k)
@@ -991,6 +1030,7 @@ func (m schemaMap) diff(
9911030
continue
9921031
}
9931032

1033+
log.Printf("[DEBUG] ignoring change of %q due to DiffSuppressFunc", attrK)
9941034
attrV = &terraform.ResourceAttrDiff{
9951035
Old: attrV.Old,
9961036
New: attrV.Old,
@@ -1437,6 +1477,60 @@ func (m schemaMap) diffString(
14371477
return nil
14381478
}
14391479

1480+
// handleDiffSuppressOnRefresh visits each of the attributes set in "new" and,
1481+
// if the corresponding schema sets both DiffSuppressFunc and
1482+
// DiffSuppressOnRefresh, checks whether the new value is materially different
1483+
// than the old and if not it overwrites the new value with the old one,
1484+
// in-place.
1485+
func (m schemaMap) handleDiffSuppressOnRefresh(ctx context.Context, oldState, newState *terraform.InstanceState) {
1486+
if newState == nil || oldState == nil {
1487+
return // nothing to do, then
1488+
}
1489+
1490+
// We'll populate this in the loop below only if we find at least one
1491+
// attribute which needs this analysis.
1492+
var d *ResourceData
1493+
1494+
oldAttrs := oldState.Attributes
1495+
newAttrs := newState.Attributes
1496+
for k, newV := range newAttrs {
1497+
oldV, ok := oldAttrs[k]
1498+
if !ok {
1499+
continue // no old value to compare with
1500+
}
1501+
if newV == oldV {
1502+
continue // no change to test
1503+
}
1504+
1505+
schemaList := addrToSchema(strings.Split(k, "."), m)
1506+
if len(schemaList) == 0 {
1507+
continue // no schema? weird, but not our responsibility to handle
1508+
}
1509+
schema := schemaList[len(schemaList)-1]
1510+
if !schema.DiffSuppressOnRefresh || schema.DiffSuppressFunc == nil {
1511+
continue // not relevant
1512+
}
1513+
1514+
if d == nil {
1515+
// We populate "d" only on demand, to avoid the cost for most
1516+
// existing schemas where DiffSuppressOnRefresh won't be set.
1517+
var err error
1518+
d, err = m.Data(newState, nil)
1519+
if err != nil {
1520+
// Should not happen if we got far enough to be doing this
1521+
// analysis, but if it does then we'll bail out.
1522+
tfsdklog.Warn(ctx, fmt.Sprintf("schemaMap.handleDiffSuppressOnRefresh failed to construct ResourceData: %s", err))
1523+
return
1524+
}
1525+
}
1526+
1527+
if schema.DiffSuppressFunc(k, oldV, newV, d) {
1528+
tfsdklog.Debug(ctx, fmt.Sprintf("ignoring change of %q due to DiffSuppressFunc", k))
1529+
newState.Attributes[k] = oldV // keep the old value, then
1530+
}
1531+
}
1532+
}
1533+
14401534
func (m schemaMap) validate(
14411535
k string,
14421536
schema *Schema,

0 commit comments

Comments
 (0)