diff --git a/.changelog/22142.txt b/.changelog/22142.txt new file mode 100644 index 00000000000..4fea91bee26 --- /dev/null +++ b/.changelog/22142.txt @@ -0,0 +1,3 @@ +```release-note:bug +resource/aws_ecr_lifecycle_policy: Fix erroneous diffs in `policy` when no changes made or policies are equivalent +``` \ No newline at end of file diff --git a/internal/service/ecr/lifecycle_policy.go b/internal/service/ecr/lifecycle_policy.go index 7265f215ab3..e792b9f6898 100644 --- a/internal/service/ecr/lifecycle_policy.go +++ b/internal/service/ecr/lifecycle_policy.go @@ -1,18 +1,23 @@ package ecr import ( + "bytes" + "encoding/json" "fmt" "log" + "sort" + "strings" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/private/protocol/json/jsonutil" "github.com/aws/aws-sdk-go/service/ecr" "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/structure" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/hashicorp/terraform-provider-aws/internal/conns" "github.com/hashicorp/terraform-provider-aws/internal/tfresource" - "github.com/hashicorp/terraform-provider-aws/internal/verify" ) func ResourceLifecyclePolicy() *schema.Resource { @@ -32,11 +37,19 @@ func ResourceLifecyclePolicy() *schema.Resource { ForceNew: true, }, "policy": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - ValidateFunc: validation.StringIsJSON, - DiffSuppressFunc: verify.SuppressEquivalentJSONDiffs, + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + equal, _ := equivalentLifecyclePolicyJSON(old, new) + + return equal + }, + StateFunc: func(v interface{}) string { + json, _ := structure.NormalizeJsonString(v) + return json + }, }, "registry_id": { Type: schema.TypeString, @@ -49,9 +62,15 @@ func ResourceLifecyclePolicy() *schema.Resource { func resourceLifecyclePolicyCreate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*conns.AWSClient).ECRConn + policy, err := structure.NormalizeJsonString(d.Get("policy").(string)) + + if err != nil { + return fmt.Errorf("policy (%s) is invalid JSON: %w", policy, err) + } + input := &ecr.PutLifecyclePolicyInput{ RepositoryName: aws.String(d.Get("repository").(string)), - LifecyclePolicyText: aws.String(d.Get("policy").(string)), + LifecyclePolicyText: aws.String(policy), } resp, err := conn.PutLifecyclePolicy(input) @@ -118,7 +137,22 @@ func resourceLifecyclePolicyRead(d *schema.ResourceData, meta interface{}) error d.Set("repository", resp.RepositoryName) d.Set("registry_id", resp.RegistryId) - d.Set("policy", resp.LifecyclePolicyText) + + equivalent, err := equivalentLifecyclePolicyJSON(d.Get("policy").(string), aws.StringValue(resp.LifecyclePolicyText)) + + if err != nil { + return fmt.Errorf("while comparing policy (state: %s) (from AWS: %s), encountered: %w", d.Get("policy").(string), aws.StringValue(resp.LifecyclePolicyText), err) + } + + if !equivalent { + policyToSet, err := structure.NormalizeJsonString(aws.StringValue(resp.LifecyclePolicyText)) + + if err != nil { + return fmt.Errorf("policy (%s) is invalid JSON: %w", policyToSet, err) + } + + d.Set("policy", policyToSet) + } return nil } @@ -143,3 +177,90 @@ func resourceLifecyclePolicyDelete(d *schema.ResourceData, meta interface{}) err return nil } + +type lifecyclePolicyRuleSelection struct { + TagStatus *string `locationName:"tagStatus" type:"string" enum:"tagStatus" required:"true"` + TagPrefixList []*string `locationName:"tagPrefixList" type:"list"` + CountType *string `locationName:"countType" type:"string" enum:"countType" required:"true"` + CountUnit *string `locationName:"countUnit" type:"string" enum:"countType"` + CountNumber *int64 `locationName:"countNumber" min:"1" type:"integer"` +} + +type lifecyclePolicyRuleAction struct { + ActionType *string `locationName:"type" type:"string" required:"true"` +} + +type lifecyclePolicyRule struct { + RulePriority *int64 `locationName:"countNumber" type:"integer" required:"true"` + Description *string `locationName:"description" type:"string"` + Selection *lifecyclePolicyRuleSelection `location:"selection" type:"structure" required:"true"` + Action *lifecyclePolicyRuleAction `location:"action" type:"structure" required:"true"` +} + +type lifecyclePolicy struct { + Rules []*lifecyclePolicyRule `locationName:"rules" min:"1" type:"list" required:"true"` +} + +func (lp *lifecyclePolicy) reduce() { + sort.Slice(lp.Rules, func(i, j int) bool { + return aws.Int64Value(lp.Rules[i].RulePriority) < aws.Int64Value(lp.Rules[j].RulePriority) + }) + + for _, rule := range lp.Rules { + rule.Selection.reduce() + } +} + +func (lprs *lifecyclePolicyRuleSelection) reduce() { + sort.Slice(lprs.TagPrefixList, func(i, j int) bool { + return aws.StringValue(lprs.TagPrefixList[i]) < aws.StringValue(lprs.TagPrefixList[j]) + }) + + if len(lprs.TagPrefixList) == 0 { + lprs.TagPrefixList = nil + } +} + +func equivalentLifecyclePolicyJSON(str1, str2 string) (bool, error) { + if strings.TrimSpace(str1) == "" { + str1 = "{}" + } + + if strings.TrimSpace(str2) == "" { + str2 = "{}" + } + + var lp1, lp2 lifecyclePolicy + + if err := json.Unmarshal([]byte(str1), &lp1); err != nil { + return false, err + } + + lp1.reduce() + + canonicalJSON1, err := jsonutil.BuildJSON(lp1) + + if err != nil { + return false, err + } + + if err := json.Unmarshal([]byte(str2), &lp2); err != nil { + return false, err + } + + lp2.reduce() + + canonicalJSON2, err := jsonutil.BuildJSON(lp2) + + if err != nil { + return false, err + } + + equal := bytes.Equal(canonicalJSON1, canonicalJSON2) + + if !equal { + log.Printf("[DEBUG] Canonical Lifecycle Policy JSONs are not equal.\nFirst: %s\nSecond: %s\n", canonicalJSON1, canonicalJSON2) + } + + return equal, nil +} diff --git a/internal/service/ecr/lifecycle_policy_test.go b/internal/service/ecr/lifecycle_policy_test.go index 28ed022a2e5..1d13ec7562a 100644 --- a/internal/service/ecr/lifecycle_policy_test.go +++ b/internal/service/ecr/lifecycle_policy_test.go @@ -40,6 +40,30 @@ func TestAccECRLifecyclePolicy_basic(t *testing.T) { }) } +func TestAccECRLifecyclePolicy_ignoreEquivalent(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ecr_lifecycle_policy.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, ecr.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckLifecyclePolicyDestroy, + Steps: []resource.TestStep{ + { + Config: testAccEcrLifecyclePolicyOrderConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckLifecyclePolicyExists(resourceName), + ), + }, + { + Config: testAccEcrLifecyclePolicyNewOrderConfig(rName), + PlanOnly: true, + }, + }, + }) +} + func testAccCheckLifecyclePolicyDestroy(s *terraform.State) error { conn := acctest.Provider.Meta().(*conns.AWSClient).ECRConn @@ -116,3 +140,99 @@ EOF } `, rName) } + +func testAccEcrLifecyclePolicyOrderConfig(rName string) string { + return fmt.Sprintf(` +resource "aws_ecr_repository" "test" { + name = %[1]q +} + +resource "aws_ecr_lifecycle_policy" "test" { + repository = aws_ecr_repository.test.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Expire images older than 14 days" + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 + } + action = { + type = "expire" + } + }, + { + rulePriority = 2 + description = "Expire tagged images older than 14 days" + selection = { + tagStatus = "tagged" + tagPrefixList = [ + "first", + "second", + "third", + ] + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 + } + action = { + type = "expire" + } + }, + ] + }) +} +`, rName) +} + +func testAccEcrLifecyclePolicyNewOrderConfig(rName string) string { + return fmt.Sprintf(` +resource "aws_ecr_repository" "test" { + name = "%s" +} + +resource "aws_ecr_lifecycle_policy" "test" { + repository = aws_ecr_repository.test.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 2 + description = "Expire tagged images older than 14 days" + selection = { + tagStatus = "tagged" + tagPrefixList = [ + "third", + "first", + "second", + ] + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 + } + action = { + type = "expire" + } + }, + { + rulePriority = 1 + description = "Expire images older than 14 days" + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 + } + action = { + type = "expire" + } + }, + ] + }) +} +`, rName) +}