From 5b11d18f8a2db33c21034875a18a7380bef86c4c Mon Sep 17 00:00:00 2001 From: Tamas Jozsa Date: Wed, 15 Apr 2026 14:45:29 +0100 Subject: [PATCH] fix: sort api_token permission_groups to match v5 provider canonical ordering The v5 provider returns permission_groups sorted alphabetically by ID, but the migrator preserved the original v4 ordering. This caused drift in E2E tests where the plan showed IDs swapped between what the config specified and what the provider expected. --- .../testdata/api_token/expected/api_token.tf | 8 ++-- internal/resources/api_token/v4_to_v5.go | 48 ++++++++++--------- internal/resources/api_token/v4_to_v5_test.go | 8 ++-- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/integration/v4_to_v5/testdata/api_token/expected/api_token.tf b/integration/v4_to_v5/testdata/api_token/expected/api_token.tf index cb7a6a1a..208885c6 100644 --- a/integration/v4_to_v5/testdata/api_token/expected/api_token.tf +++ b/integration/v4_to_v5/testdata/api_token/expected/api_token.tf @@ -27,9 +27,9 @@ resource "cloudflare_api_token" "basic_token" { "com.cloudflare.api.account.*" = "*" }) permission_groups = [{ - id = "c8fed203ed3043cba015a93ad1616f1f" - }, { id = "82e64a83756745bbbb1c9c2701bf816b" + }, { + id = "c8fed203ed3043cba015a93ad1616f1f" }] }] } @@ -237,9 +237,9 @@ resource "cloudflare_api_token" "multi_perms_token" { "com.cloudflare.api.account.*" = "*" }) permission_groups = [{ - id = "c8fed203ed3043cba015a93ad1616f1f" - }, { id = "82e64a83756745bbbb1c9c2701bf816b" + }, { + id = "c8fed203ed3043cba015a93ad1616f1f" }] }] } diff --git a/internal/resources/api_token/v4_to_v5.go b/internal/resources/api_token/v4_to_v5.go index e8b27513..54c823f9 100644 --- a/internal/resources/api_token/v4_to_v5.go +++ b/internal/resources/api_token/v4_to_v5.go @@ -2,6 +2,7 @@ package api_token import ( "regexp" + "sort" "github.com/cloudflare/tf-migrate/internal" "github.com/cloudflare/tf-migrate/internal/transform" @@ -95,6 +96,7 @@ func (m *V4ToV5Migrator) transformPolicyBlocks(body *hclwrite.Body) { // transformPermissionGroups converts permission_groups from list of strings to list of objects // v4: permission_groups = ["id1", "id2"] // v5: permission_groups = [{ id = "id1" }, { id = "id2" }] +// The IDs are sorted alphabetically to match the v5 provider's canonical ordering. func (m *V4ToV5Migrator) transformPermissionGroups(body *hclwrite.Body) { permGroupsAttr := body.GetAttribute("permission_groups") if permGroupsAttr == nil { @@ -104,13 +106,9 @@ func (m *V4ToV5Migrator) transformPermissionGroups(body *hclwrite.Body) { // Parse the existing list expression to extract the permission IDs exprTokens := permGroupsAttr.Expr().BuildTokens(nil) - // Build a list of objects where each string ID becomes { id = "..." } - var permObjects []hclwrite.Tokens - - // We need to manually parse the tokens to extract string values - // For simplicity, we'll reconstruct the structure + // Collect all permission group IDs first + var permIDs []string inList := false - var currentID string for _, token := range exprTokens { switch token.Type { @@ -120,26 +118,33 @@ func (m *V4ToV5Migrator) transformPermissionGroups(body *hclwrite.Body) { inList = false case hclsyntax.TokenQuotedLit: if inList { - // Extract the ID from the quoted literal (remove quotes) - currentID = string(token.Bytes) - // Create an object: { id = "currentID" } - objAttrs := []hclwrite.ObjectAttrTokens{ - { - Name: hclwrite.TokensForIdentifier("id"), - Value: hclwrite.TokensForValue(cty.StringVal(currentID)), - }, - } - permObjects = append(permObjects, hclwrite.TokensForObject(objAttrs)) + permIDs = append(permIDs, string(token.Bytes)) } } } - // If we found any permission IDs, replace the attribute with the new format - if len(permObjects) > 0 { - body.RemoveAttribute("permission_groups") - listTokens := hclwrite.TokensForTuple(permObjects) - body.SetAttributeRaw("permission_groups", listTokens) + if len(permIDs) == 0 { + return + } + + // Sort IDs alphabetically to match the v5 provider's canonical ordering + sort.Strings(permIDs) + + // Build a list of objects where each string ID becomes { id = "..." } + var permObjects []hclwrite.Tokens + for _, id := range permIDs { + objAttrs := []hclwrite.ObjectAttrTokens{ + { + Name: hclwrite.TokensForIdentifier("id"), + Value: hclwrite.TokensForValue(cty.StringVal(id)), + }, + } + permObjects = append(permObjects, hclwrite.TokensForObject(objAttrs)) } + + body.RemoveAttribute("permission_groups") + listTokens := hclwrite.TokensForTuple(permObjects) + body.SetAttributeRaw("permission_groups", listTokens) } // transformResources wraps the resources map with jsonencode() @@ -200,4 +205,3 @@ func (m *V4ToV5Migrator) transformConditionBlock(body *hclwrite.Body) { body.SetAttributeRaw("condition", conditionTokens) body.RemoveBlock(conditionBlock) } - diff --git a/internal/resources/api_token/v4_to_v5_test.go b/internal/resources/api_token/v4_to_v5_test.go index 9f6208d3..fa775622 100644 --- a/internal/resources/api_token/v4_to_v5_test.go +++ b/internal/resources/api_token/v4_to_v5_test.go @@ -59,9 +59,9 @@ resource "cloudflare_api_token" "example" { "com.cloudflare.api.account.*" = "*" }) permission_groups = [{ - id = "c8fed203ed3043cba015a93ad1616f1f" - }, { id = "82e64a83756745bbbb1c9c2701bf816b" + }, { + id = "c8fed203ed3043cba015a93ad1616f1f" }] }] }`, @@ -461,9 +461,9 @@ resource "cloudflare_api_token" "full_example" { "com.cloudflare.api.account.billing.*" = "read" }) permission_groups = [{ - id = "c8fed203ed3043cba015a93ad1616f1f" - }, { id = "82e64a83756745bbbb1c9c2701bf816b" + }, { + id = "c8fed203ed3043cba015a93ad1616f1f" }] }, { effect = "deny"