diff --git a/pkg/tests/cross-tests/adapt.go b/pkg/tests/cross-tests/adapt.go index e7ca74ded..8594809ef 100644 --- a/pkg/tests/cross-tests/adapt.go +++ b/pkg/tests/cross-tests/adapt.go @@ -102,9 +102,14 @@ func (ta *typeAdapter) NewValue(value any) tftypes.Value { switch v := value.(type) { case map[string]any: values := map[string]tftypes.Value{} - for k, el := range v { - values[k] = fromType(aT[k]).NewValue(el) + for key, expectedType := range aT { + if vk, ok := v[key]; ok { + values[key] = fromType(expectedType).NewValue(vk) + } else { + values[key] = tftypes.NewValue(expectedType, nil) + } } + // Could detect and warn on extraneous keys here but do not do that for now. return tftypes.NewValue(t, values) } } @@ -112,7 +117,8 @@ func (ta *typeAdapter) NewValue(value any) tftypes.Value { } func fromType(t tftypes.Type) *typeAdapter { - return &typeAdapter{t} + contract.Assertf(t != nil, "t cannot be nil here") + return &typeAdapter{typ: t} } type valueAdapter struct { diff --git a/pkg/tests/cross-tests/cross_test.go b/pkg/tests/cross-tests/cross_test.go index a7f6bae46..7a0eaf37b 100644 --- a/pkg/tests/cross-tests/cross_test.go +++ b/pkg/tests/cross-tests/cross_test.go @@ -25,6 +25,8 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + webaclschema "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tests/internal/webaclschema" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -403,3 +405,287 @@ func TestAws2442(t *testing.T) { Config2: cfg2, }) } + +func TestHash(t *testing.T) { + expected := map[string]interface{}{ + "action": []interface{}{map[string]interface{}{ + "allow": []interface{}{}, + "block": []interface{}{interface{}(nil)}, + "captcha": []interface{}{}, + "challenge": []interface{}{}, + "count": []interface{}{}}}, + "captcha_config": []interface{}{}, + "name": "IPAllowRule", + "override_action": []interface{}{}, + "priority": 0, + //"rule_label": schema.NewSet(nil, nil), + "statement": []interface{}{map[string]interface{}{ + "and_statement": []interface{}{}, + "byte_match_statement": []interface{}{}, + "geo_match_statement": []interface{}{}, + "ip_set_reference_statement": []interface{}{map[string]interface{}{ + "arn": "some-arn", + "ip_set_forwarded_ip_config": []interface{}{}, + }}, + "label_match_statement": []interface{}{}, + "managed_rule_group_statement": []interface{}{}, + "not_statement": []interface{}{}, + "or_statement": []interface{}{}, + "rate_based_statement": []interface{}{}, + "regex_match_statement": []interface{}{}, + "regex_pattern_set_reference_statement": []interface{}{}, + "rule_group_reference_statement": []interface{}{}, + "size_constraint_statement": []interface{}{}, + "sqli_match_statement": []interface{}{}, + "xss_match_statement": []interface{}{}}}, + "visibility_config": []interface{}{map[string]interface{}{ + "cloudwatch_metrics_enabled": true, + "metric_name": "IPAllowRule", + "sampled_requests_enabled": true, + }}} + resource := webaclschema.ResourceWebACL() + resource.Schema = resource.SchemaFunc() + for i := 0; i < 100; i++ { + t.Logf("@ %d", i) + actual := schema.HashResource(resource.Schema["rule"].Elem.(*schema.Resource))(expected) + assert.Equalf(t, 835885598, actual, "attempt %d", i) + } +} + +func TestAws3880(t *testing.T) { + cfg := map[string]any{ + "scope": "REGIONAL", + "name": "autogenerated-name", + "default_action": []any{ + map[string]any{ + "allow": []any{map[string]any{}}, + }, + }, + "visibility_config": []any{ + map[string]any{ + "cloudwatch_metrics_enabled": true, + "metric_name": "myWebAclMetrics", + "sampled_requests_enabled": false, + }, + }, + "rule": []any{ + map[string]any{ + "action": []any{ + map[string]any{ + "block": []any{map[string]any{}}, + }, + }, + "name": "IPAllowRule", + "priority": 0, + "statement": []any{ + map[string]any{ + "ip_set_reference_statement": []any{map[string]any{ + "arn": "some-arn", + }}, + }, + }, + "visibility_config": []any{ + map[string]any{ + "cloudwatch_metrics_enabled": true, + "metric_name": "IPAllowRule", + "sampled_requests_enabled": true, + }, + }, + }, + }, + } + + resource := webaclschema.ResourceWebACL() + resource.Schema = resource.SchemaFunc() + resource.SchemaFunc = nil + delete(resource.Schema, "tags") + delete(resource.Schema, "tags_all") + + // Here i may receive maps or slices over base types and *schema.Set which is not friendly to diffing. + resource.Schema["rule"].Set = func(i interface{}) int { + actual := schema.HashResource(resource.Schema["rule"].Elem.(*schema.Resource))(i) + + require.NotEqualf(t, 835885598, actual, "This number does not happen under TF") + + im := i.(map[string]interface{}) + ruleLabel := im["rule_label"] + action := im["action"].([]any)[0].(map[string]interface{})["block"] + + delete(im, "rule_label") + + expected := map[string]interface{}{ + "action": []interface{}{map[string]interface{}{ + "allow": []interface{}{}, + "block": []interface{}{interface{}(nil)}, + "captcha": []interface{}{}, + "challenge": []interface{}{}, + "count": []interface{}{}}}, + "captcha_config": []interface{}{}, + "name": "IPAllowRule", + "override_action": []interface{}{}, + "priority": 0, + // "rule_label": []interface{}{}, + // "rule_label_type": "set", + "statement": []interface{}{map[string]interface{}{ + "and_statement": []interface{}{}, + "byte_match_statement": []interface{}{}, + "geo_match_statement": []interface{}{}, + "ip_set_reference_statement": []interface{}{map[string]interface{}{ + "arn": "some-arn", + "ip_set_forwarded_ip_config": []interface{}{}, + }}, + "label_match_statement": []interface{}{}, + "managed_rule_group_statement": []interface{}{}, + "not_statement": []interface{}{}, + "or_statement": []interface{}{}, + "rate_based_statement": []interface{}{}, + "regex_match_statement": []interface{}{}, + "regex_pattern_set_reference_statement": []interface{}{}, + "rule_group_reference_statement": []interface{}{}, + "size_constraint_statement": []interface{}{}, + "sqli_match_statement": []interface{}{}, + "xss_match_statement": []interface{}{}}}, + "visibility_config": []interface{}{map[string]interface{}{ + "cloudwatch_metrics_enabled": true, + "metric_name": "IPAllowRule", + "sampled_requests_enabled": true, + }}} + + expected2 := map[string]interface{}{ + "action": []interface{}{map[string]interface{}{ + "allow": []interface{}{}, + "block": []interface{}{ + map[string]any{ + "custom_response": []interface{}{}, + }, + }, + "captcha": []interface{}{}, + "challenge": []interface{}{}, + "count": []interface{}{}}}, + "captcha_config": []interface{}{}, + "name": "IPAllowRule", + "override_action": []interface{}{}, + "priority": 0, + //"rule_label": schema.NewSet(nil, nil), + "statement": []interface{}{map[string]interface{}{ + "and_statement": []interface{}{}, + "byte_match_statement": []interface{}{}, + "geo_match_statement": []interface{}{}, + "ip_set_reference_statement": []interface{}{map[string]interface{}{ + "arn": "some-arn", + "ip_set_forwarded_ip_config": []interface{}{}, + }}, + "label_match_statement": []interface{}{}, + "managed_rule_group_statement": []interface{}{}, + "not_statement": []interface{}{}, + "or_statement": []interface{}{}, + "rate_based_statement": []interface{}{}, + "regex_match_statement": []interface{}{}, + "regex_pattern_set_reference_statement": []interface{}{}, + "rule_group_reference_statement": []interface{}{}, + "size_constraint_statement": []interface{}{}, + "sqli_match_statement": []interface{}{}, + "xss_match_statement": []interface{}{}}}, + "visibility_config": []interface{}{map[string]interface{}{ + "cloudwatch_metrics_enabled": true, + "metric_name": "IPAllowRule", + "sampled_requests_enabled": true, + }}} + + switch { + case assert.ObjectsAreEqual(expected, i): + fmt.Printf("\n\n#### Computing hash set for rule <> (action=%#v, ruleLabel=%#v)==> %d\n\n", action, ruleLabel, actual) + case assert.ObjectsAreEqual(expected2, i): + fmt.Printf("\n\n#### Computing hash set for rule <> (action=%#v, ruleLabel=%#v)==> %d\n\n", action, ruleLabel, actual) + default: + assert.Equal(t, expected, i) + } + return actual + } + + runDiffCheck(t, diffTestCase{ + Resource: resource, + Config1: cfg, + Config2: cfg, + SkipPulumi: false, + }) +} + +func TestAws3880Minimal(t *testing.T) { + customResponseSchema := func() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "custom_response_body_key": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + } + } + blockConfigSchema := func() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "custom_response": customResponseSchema(), + }, + }, + } + } + ruleElement := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "action": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "block": blockConfigSchema(), + }, + }, + }, + }, + } + cfg := map[string]any{ + "rule": []any{ + map[string]any{ + "action": []any{ + map[string]any{ + "block": []any{map[string]any{}}, + }, + }, + }, + }, + } + + resource := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "rule": { + Type: schema.TypeSet, + Optional: true, + Elem: ruleElement, + }, + }, + } + + // Here i may receive maps or slices over base types and *schema.Set which is not friendly to diffing. + resource.Schema["rule"].Set = func(i interface{}) int { + actual := schema.HashResource(resource.Schema["rule"].Elem.(*schema.Resource))(i) + fmt.Printf("hashing %#v as %d\n", i, actual) + return actual + } + + runDiffCheck(t, diffTestCase{ + Resource: resource, + Config1: cfg, + Config2: cfg, + SkipPulumi: false, + }) +} diff --git a/pkg/tests/cross-tests/diff_check.go b/pkg/tests/cross-tests/diff_check.go index 4adfd8ef3..3a7a109b7 100644 --- a/pkg/tests/cross-tests/diff_check.go +++ b/pkg/tests/cross-tests/diff_check.go @@ -44,6 +44,8 @@ type diffTestCase struct { // Optional object type for the resource. If left nil will be inferred from Resource schema. ObjectType *tftypes.Object + + SkipPulumi bool } func runDiffCheck(t T, tc diffTestCase) { @@ -58,8 +60,12 @@ func runDiffCheck(t T, tc diffTestCase) { tfwd := t.TempDir() tfd := newTfDriver(t, tfwd, providerShortName, rtype, tc.Resource) - _ = tfd.writePlanApply(t, tc.Resource.Schema, rtype, "example", tc.Config1) - tfDiffPlan := tfd.writePlanApply(t, tc.Resource.Schema, rtype, "example", tc.Config2) + _ = tfd.writePlanApply(t, tc.Resource.SchemaMap(), rtype, "example", tc.Config1) + tfDiffPlan := tfd.writePlanApply(t, tc.Resource.SchemaMap(), rtype, "example", tc.Config2) + + if tc.SkipPulumi { + return + } tfp := &schema.Provider{ ResourcesMap: map[string]*schema.Resource{ @@ -102,6 +108,7 @@ func runDiffCheck(t T, tc diffTestCase) { x := pt.Up() tfAction := tfd.parseChangesFromTFPlan(*tfDiffPlan) + t.Logf("Terraform decided to take the %q action", tfAction) tc.verifyBasicDiffAgreement(t, tfAction, x.Summary) } diff --git a/pkg/tests/cross-tests/tf_driver.go b/pkg/tests/cross-tests/tf_driver.go index 1d5c9eb57..0af6fe229 100644 --- a/pkg/tests/cross-tests/tf_driver.go +++ b/pkg/tests/cross-tests/tf_driver.go @@ -119,8 +119,7 @@ func (d *tfDriver) coalesce(t T, x any) *tftypes.Value { if x == nil { return nil } - objectType := convert.InferObjectType(sdkv2.NewSchemaMap(d.res.Schema), nil) - t.Logf("infer object type: %v", objectType) + objectType := convert.InferObjectType(sdkv2.NewSchemaMap(d.res.SchemaMap()), nil) v := fromType(objectType).NewValue(x) return &v } diff --git a/pkg/tests/cross-tests/tfwrite.go b/pkg/tests/cross-tests/tfwrite.go index eb9402ae2..7aa90ab8f 100644 --- a/pkg/tests/cross-tests/tfwrite.go +++ b/pkg/tests/cross-tests/tfwrite.go @@ -56,14 +56,18 @@ func writeBlock(body *hclwrite.Body, schemas map[string]*schema.Schema, values m if sch.Type == schema.TypeMap { body.SetAttributeValue(key, value) } else if sch.Type == schema.TypeSet { - for _, v := range value.AsValueSet().Values() { - newBlock := body.AppendNewBlock(key, nil) - writeBlock(newBlock.Body(), elem.Schema, v.AsValueMap()) + if !value.IsNull() { + for _, v := range value.AsValueSet().Values() { + newBlock := body.AppendNewBlock(key, nil) + writeBlock(newBlock.Body(), elem.Schema, v.AsValueMap()) + } } } else if sch.Type == schema.TypeList { - for _, v := range value.AsValueSlice() { - newBlock := body.AppendNewBlock(key, nil) - writeBlock(newBlock.Body(), elem.Schema, v.AsValueMap()) + if !value.IsNull() { + for _, v := range value.AsValueSlice() { + newBlock := body.AppendNewBlock(key, nil) + writeBlock(newBlock.Body(), elem.Schema, v.AsValueMap()) + } } } else { contract.Failf("unexpected schema type %v", sch.Type) diff --git a/pkg/tests/internal/webaclschema/webacl.go b/pkg/tests/internal/webaclschema/webacl.go index bfa60e1b5..7b29e5e97 100644 --- a/pkg/tests/internal/webaclschema/webacl.go +++ b/pkg/tests/internal/webaclschema/webacl.go @@ -6,10 +6,8 @@ package wafv2 import ( - "bytes" "context" "fmt" - "hash/crc32" "regexp" "strings" @@ -18,17 +16,17 @@ import ( ) func ResourceWebACL() *schema.Resource { - hashcodeString := func(s string) int { - v := int(crc32.ChecksumIEEE([]byte(s))) - if v >= 0 { - return v - } - if -v >= 0 { - return -v - } - // v == MinInt - return 0 - } + // hashcodeString := func(s string) int { + // v := int(crc32.ChecksumIEEE([]byte(s))) + // if v >= 0 { + // return v + // } + // if -v >= 0 { + // return -v + // } + // // v == MinInt + // return 0 + // } ruleElement := &schema.Resource{ Schema: map[string]*schema.Schema{ @@ -138,20 +136,20 @@ func ResourceWebACL() *schema.Resource { }, "rule": { Type: schema.TypeSet, - Set: func(v interface{}) int { - var buf bytes.Buffer - schema.SerializeResourceForHash(&buf, v, ruleElement) - // before := "action:(;);" - // after := "action:(;);" - s := buf.String() - //s = strings.ReplaceAll(s, before, after) - n := hashcodeString(s) - if 1+2 == 18 { - fmt.Printf("PRE-HASH:\n%s\n\n", s) - fmt.Printf("HASHED: %d\n", n) - } - return n - }, + // Set: func(v interface{}) int { + // var buf bytes.Buffer + // schema.SerializeResourceForHash(&buf, v, ruleElement) + // // before := "action:(;);" + // // after := "action:(;);" + // s := buf.String() + // //s = strings.ReplaceAll(s, before, after) + // n := hashcodeString(s) + // if 1+2 == 18 { + // fmt.Printf("PRE-HASH:\n%s\n\n", s) + // fmt.Printf("HASHED: %d\n", n) + // } + // return n + // }, Optional: true, Elem: ruleElement, }, @@ -161,8 +159,6 @@ func ResourceWebACL() *schema.Resource { ForceNew: true, //ValidateFunc: validation.StringInSlice(wafv2.Scope_Values(), false), }, - // names.AttrTags: tftags.TagsSchema(), - // names.AttrTagsAll: tftags.TagsSchemaTrulyComputed(), "token_domains": { Type: schema.TypeSet, Optional: true, diff --git a/pkg/tfbridge/provider.go b/pkg/tfbridge/provider.go index 01a2db418..abc17c5d8 100644 --- a/pkg/tfbridge/provider.go +++ b/pkg/tfbridge/provider.go @@ -960,7 +960,7 @@ func (p *Provider) Diff(ctx context.Context, req *pulumirpc.DiffRequest) (*pulum // We will still use `detailedDiff` for diff display purposes. // See also https://github.com/pulumi/pulumi-terraform-bridge/issues/1501. - if len(diff.Attributes()) > 0 { + if !diff.HasNoChanges() { changes = pulumirpc.DiffResponse_DIFF_SOME // Perhaps collectionDiffs can shed some light and locate the changes to the end-user. for path, diff := range dd.collectionDiffs { diff --git a/pkg/tfbridge/tests/provider_test.go b/pkg/tfbridge/tests/provider_test.go index c7296ed85..02c1cf0a1 100644 --- a/pkg/tfbridge/tests/provider_test.go +++ b/pkg/tfbridge/tests/provider_test.go @@ -3,6 +3,7 @@ package tfbridgetests import ( "context" "encoding/json" + "fmt" "io" "testing" @@ -68,6 +69,136 @@ func TestWithNewTestProvider(t *testing.T) { `) } +func TestReproMinimalDiffCycle(t *testing.T) { + customResponseSchema := func() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "custom_response_body_key": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + } + } + blockConfigSchema := func() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "custom_response": customResponseSchema(), + }, + }, + } + } + ruleElement := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "action": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "block": blockConfigSchema(), + }, + }, + }, + }, + } + + resource := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "rule": { + Type: schema.TypeSet, + Optional: true, + Elem: ruleElement, + }, + }, + } + + // Here i may receive maps or slices over base types and *schema.Set which is not friendly to diffing. + resource.Schema["rule"].Set = func(i interface{}) int { + actual := schema.HashResource(resource.Schema["rule"].Elem.(*schema.Resource))(i) + fmt.Printf("hashing %#v as %d\n", i, actual) + return actual + } + ctx := context.Background() + p := newTestProvider(ctx, tfbridge.ProviderInfo{ + P: shimv2.NewProvider(&schema.Provider{ + Schema: map[string]*schema.Schema{}, + ResourcesMap: map[string]*schema.Resource{ + "example_resource": resource, + }, + }, shimv2.WithPlanResourceChange(func(tfResourceType string) bool { + return true + })), + Name: "testprov", + ResourcePrefix: "example", + Resources: map[string]*tfbridge.ResourceInfo{ + "example_resource": {Tok: "testprov:index:ExampleResource"}, + }, + }, newTestProviderOptions{}) + + replay.Replay(t, p, ` + { + "method": "/pulumirpc.ResourceProvider/Diff", + "request": { + "id": "newid", + "urn": "urn:pulumi:test::project::testprov:index:ExampleResource::example", + "olds": { + "id": "newid", + "rules": [ + { + "action": { + "block": { + "customResponse": null + } + } + } + ] + }, + "news": { + "__defaults": [], + "rules": [ + { + "__defaults": [], + "action": { + "__defaults": [], + "block": { + "__defaults": [] + } + } + } + ] + }, + "oldInputs": { + "__defaults": [], + "rules": [ + { + "__defaults": [], + "action": { + "__defaults": [], + "block": { + "__defaults": [] + } + } + } + ] + } + }, + "response": { + "changes": "DIFF_NONE", + "hasDetailedDiff": true + } + }`) +} + func nilSink() diag.Sink { nilSink := diag.DefaultSink(io.Discard, io.Discard, diag.FormatOptions{ Color: colors.Never, diff --git a/pkg/tfshim/sdk-v1/instance_diff.go b/pkg/tfshim/sdk-v1/instance_diff.go index d347a1dc7..2badd8c27 100644 --- a/pkg/tfshim/sdk-v1/instance_diff.go +++ b/pkg/tfshim/sdk-v1/instance_diff.go @@ -57,6 +57,10 @@ func (d v1InstanceDiff) Attribute(key string) *shim.ResourceAttrDiff { return resourceAttrDiffToShim(d.tf.Attributes[key]) } +func (d v1InstanceDiff) HasNoChanges() bool { + return len(d.Attributes()) == 0 +} + func (d v1InstanceDiff) Attributes() map[string]shim.ResourceAttrDiff { m := map[string]shim.ResourceAttrDiff{} for k, v := range d.tf.Attributes { diff --git a/pkg/tfshim/sdk-v2/instance_diff.go b/pkg/tfshim/sdk-v2/instance_diff.go index aaac5c00f..c12ad306a 100644 --- a/pkg/tfshim/sdk-v2/instance_diff.go +++ b/pkg/tfshim/sdk-v2/instance_diff.go @@ -46,6 +46,10 @@ func (d v2InstanceDiff) Attribute(key string) *shim.ResourceAttrDiff { return resourceAttrDiffToShim(d.tf.Attributes[key]) } +func (d v2InstanceDiff) HasNoChanges() bool { + return len(d.Attributes()) == 0 +} + func (d v2InstanceDiff) Attributes() map[string]shim.ResourceAttrDiff { m := map[string]shim.ResourceAttrDiff{} for k, v := range d.tf.Attributes { diff --git a/pkg/tfshim/sdk-v2/provider2.go b/pkg/tfshim/sdk-v2/provider2.go index e4428670e..627eeb8bc 100644 --- a/pkg/tfshim/sdk-v2/provider2.go +++ b/pkg/tfshim/sdk-v2/provider2.go @@ -444,6 +444,17 @@ func (s *grpcServer) PlanResourceChange( if err != nil { return nil, err } + + // There are cases where planned state is equal to the original state, but InstanceDiff still displays changes. + // Pulumi considers this to be a no-change diff, and as a workaround here any InstanceDiff changes are deleted + // and ignored (simlar to processIgnoreChanges). + // + // See pulumi/pulumi-aws#3880 + same := plannedState.Equals(priorState) + if same.IsKnown() && same.True() { + resp.InstanceDiff.Attributes = map[string]*terraform.ResourceAttrDiff{} + } + var meta map[string]interface{} if resp.PlannedPrivate != nil { if err := json.Unmarshal(resp.PlannedPrivate, &meta); err != nil { diff --git a/pkg/tfshim/shim.go b/pkg/tfshim/shim.go index 38b7175b5..23b3f4161 100644 --- a/pkg/tfshim/shim.go +++ b/pkg/tfshim/shim.go @@ -43,7 +43,7 @@ type ResourceAttrDiff struct { type InstanceDiff interface { Attribute(key string) *ResourceAttrDiff - Attributes() map[string]ResourceAttrDiff + HasNoChanges() bool ProposedState(res Resource, priorState InstanceState) (InstanceState, error) Destroy() bool RequiresNew() bool diff --git a/pkg/tfshim/tfplugin5/instance_diff.go b/pkg/tfshim/tfplugin5/instance_diff.go index 75f6d206c..c8848f6cb 100644 --- a/pkg/tfshim/tfplugin5/instance_diff.go +++ b/pkg/tfshim/tfplugin5/instance_diff.go @@ -63,6 +63,10 @@ func (d *instanceDiff) Attributes() map[string]shim.ResourceAttrDiff { return d.attributes } +func (d *instanceDiff) HasNoChanges() bool { + return len(d.attributes) == 0 +} + func (d *instanceDiff) ProposedState(res shim.Resource, priorState shim.InstanceState) (shim.InstanceState, error) { plannedObject, err := ctyToGo(d.planned) if err != nil {