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 208885c6..b14ecd9a 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 @@ -187,6 +187,8 @@ resource "cloudflare_api_token" "full_example" { } # Test Case 8: Token with data reference and timestamps +data "cloudflare_api_token_permission_groups_list" "all" {} + resource "cloudflare_api_token" "api_token_create" { name = "${local.name_prefix} api_token_create" diff --git a/internal/datasources/api_token_permission_groups/v4_to_v5.go b/internal/datasources/api_token_permission_groups/v4_to_v5.go new file mode 100644 index 00000000..28d083d0 --- /dev/null +++ b/internal/datasources/api_token_permission_groups/v4_to_v5.go @@ -0,0 +1,54 @@ +package api_token_permission_groups + +import ( + "github.com/hashicorp/hcl/v2/hclwrite" + + "github.com/cloudflare/tf-migrate/internal" + "github.com/cloudflare/tf-migrate/internal/transform" + tfhcl "github.com/cloudflare/tf-migrate/internal/transform/hcl" +) + +// V4ToV5Migrator handles the cloudflare_api_token_permission_groups data source. +// +// In v4, this data source returns a map of permission names to IDs (e.g. +// data.cloudflare_api_token_permission_groups.all.permissions["DNS Read"]). +// +// In v5, this data source was replaced by cloudflare_api_token_permission_groups_list, +// which returns a list of objects with id/name/scopes fields. The migrator renames +// the block so that the data source is still available for reference resolution. +// +// Any expression references in cloudflare_api_token.permission_groups that use the +// old map-style lookups are rewritten by the api_token resource migrator to use +// for-expressions against the new list-style data source. +type V4ToV5Migrator struct{} + +// NewV4ToV5Migrator creates a new migrator for the +// cloudflare_api_token_permission_groups datasource (v4 → v5). +func NewV4ToV5Migrator() transform.ResourceTransformer { + migrator := &V4ToV5Migrator{} + internal.RegisterMigrator("data.cloudflare_api_token_permission_groups", "v4", "v5", migrator) + return migrator +} + +func (m *V4ToV5Migrator) GetResourceType() string { + return "cloudflare_api_token_permission_groups" +} + +func (m *V4ToV5Migrator) CanHandle(resourceType string) bool { + return resourceType == "data.cloudflare_api_token_permission_groups" +} + +func (m *V4ToV5Migrator) Preprocess(content string) string { + return content +} + +// TransformConfig renames the data source block from +// cloudflare_api_token_permission_groups to cloudflare_api_token_permission_groups_list. +func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite.Block) (*transform.TransformResult, error) { + tfhcl.RenameResourceType(block, "cloudflare_api_token_permission_groups", "cloudflare_api_token_permission_groups_list") + + return &transform.TransformResult{ + Blocks: []*hclwrite.Block{block}, + RemoveOriginal: false, + }, nil +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 9a1be53b..6a4f7c9e 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -3,6 +3,7 @@ package registry import ( accountrolesdata "github.com/cloudflare/tf-migrate/internal/datasources/account_roles" accountsdata "github.com/cloudflare/tf-migrate/internal/datasources/accounts" + apitokenpermgroupsdata "github.com/cloudflare/tf-migrate/internal/datasources/api_token_permission_groups" lbpoolsdata "github.com/cloudflare/tf-migrate/internal/datasources/load_balancer_pools" rulesetsdata "github.com/cloudflare/tf-migrate/internal/datasources/rulesets" zonedata "github.com/cloudflare/tf-migrate/internal/datasources/zone" @@ -97,6 +98,7 @@ func RegisterAllMigrations() { // Datasources accountrolesdata.NewV4ToV5Migrator() accountsdata.NewV4ToV5Migrator() + apitokenpermgroupsdata.NewV4ToV5Migrator() lbpoolsdata.NewV4ToV5Migrator() rulesetsdata.NewV4ToV5Migrator() zonedata.NewV4ToV5Migrator() diff --git a/internal/resources/api_token/v4_to_v5.go b/internal/resources/api_token/v4_to_v5.go index 54c823f9..25634567 100644 --- a/internal/resources/api_token/v4_to_v5.go +++ b/internal/resources/api_token/v4_to_v5.go @@ -1,15 +1,17 @@ package api_token import ( - "regexp" + "fmt" "sort" - "github.com/cloudflare/tf-migrate/internal" - "github.com/cloudflare/tf-migrate/internal/transform" - tfhcl "github.com/cloudflare/tf-migrate/internal/transform/hcl" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" + + "github.com/cloudflare/tf-migrate/internal" + "github.com/cloudflare/tf-migrate/internal/transform" + tfhcl "github.com/cloudflare/tf-migrate/internal/transform/hcl" ) type V4ToV5Migrator struct { @@ -29,12 +31,10 @@ func (m *V4ToV5Migrator) CanHandle(resourceType string) bool { return resourceType == "cloudflare_api_token" } +// Preprocess handles string-level transformations before HCL parsing. +// The cloudflare_api_token_permission_groups data source is now handled +// by the dedicated datasource migrator in internal/datasources/api_token_permission_groups. func (m *V4ToV5Migrator) Preprocess(content string) string { - // Remove deprecated data source cloudflare_api_token_permission_groups - // It was replaced by cloudflare_api_token_permission_groups_list in v5 - // Remove just the data source line, preserving any comments - re := regexp.MustCompile(`(?m)^data\s+"cloudflare_api_token_permission_groups"\s+"[^"]+"\s*\{\s*\}\s*\n`) - content = re.ReplaceAllString(content, "") return content } @@ -42,8 +42,8 @@ func (m *V4ToV5Migrator) Postprocess(content string) string { return content } -// GetResourceRename implements the ResourceRenamer interface -// This resource does not rename, so we return the same name for both old and new +// GetResourceRename implements the ResourceRenamer interface. +// This resource does not rename, so we return the same name for both old and new. func (m *V4ToV5Migrator) GetResourceRename() ([]string, string) { return []string{"cloudflare_api_token"}, "cloudflare_api_token" } @@ -51,7 +51,7 @@ func (m *V4ToV5Migrator) GetResourceRename() ([]string, string) { func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite.Block) (*transform.TransformResult, error) { body := block.Body() - m.transformPolicyBlocks(body) + m.transformPolicyBlocks(ctx, body) m.transformConditionBlock(body) return &transform.TransformResult{ Blocks: []*hclwrite.Block{block}, @@ -59,7 +59,7 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite }, nil } -func (m *V4ToV5Migrator) transformPolicyBlocks(body *hclwrite.Body) { +func (m *V4ToV5Migrator) transformPolicyBlocks(ctx *transform.Context, body *hclwrite.Body) { policyBlocks := tfhcl.FindBlocksByType(body, "policy") if len(policyBlocks) == 0 { return @@ -76,7 +76,7 @@ func (m *V4ToV5Migrator) transformPolicyBlocks(body *hclwrite.Body) { } // Transform permission_groups from list of strings to list of objects with id field - m.transformPermissionGroups(policyBody) + m.transformPermissionGroups(ctx, policyBody) // Transform resources from map to jsonencode() wrapped map // v4: resources = { "com.cloudflare.api.account.*" = "*" } @@ -93,58 +93,266 @@ func (m *V4ToV5Migrator) transformPolicyBlocks(body *hclwrite.Body) { tfhcl.RemoveBlocksByType(body, "policy") } -// 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) { +// transformPermissionGroups converts permission_groups from v4 format to v5 format. +// +// v4 accepts: +// - A list of bare string IDs: ["id1", "id2"] +// - A list of objects: [{ id = "id1" }, { id = "id2" }] +// - A list of data-source refs: [data.cloudflare_api_token_permission_groups.all.permissions["Name"]] +// +// v5 requires a list of objects: [{ id = "id1" }, { id = "id2" }] +// +// For string / object elements the IDs are sorted alphabetically to match +// the v5 provider's canonical ordering. +// +// Expression elements that reference cloudflare_api_token_permission_groups are +// transformed to for-expressions that look up the ID by name from the v5 +// replacement data source (cloudflare_api_token_permission_groups_list). +func (m *V4ToV5Migrator) transformPermissionGroups(ctx *transform.Context, body *hclwrite.Body) { permGroupsAttr := body.GetAttribute("permission_groups") if permGroupsAttr == nil { return } - // Parse the existing list expression to extract the permission IDs exprTokens := permGroupsAttr.Expr().BuildTokens(nil) - // Collect all permission group IDs first + // Scan for string IDs using bracket-depth tracking. + // Only TokenQuotedLit tokens at bracketDepth == 1 are direct string values; + // tokens at deeper depths (e.g. ["key"] index access on a data source) are + // NOT collected — this is the fix for the bug where "DNS Read" (the map key + // in data.source.permissions["DNS Read"]) was incorrectly extracted as an ID. var permIDs []string - inList := false - - for _, token := range exprTokens { - switch token.Type { + bracketDepth := 0 + for _, tok := range exprTokens { + switch tok.Type { case hclsyntax.TokenOBrack: - inList = true + bracketDepth++ case hclsyntax.TokenCBrack: - inList = false + bracketDepth-- case hclsyntax.TokenQuotedLit: - if inList { - permIDs = append(permIDs, string(token.Bytes)) + if bracketDepth == 1 { + permIDs = append(permIDs, string(tok.Bytes)) } } } - if len(permIDs) == 0 { + if len(permIDs) > 0 { + // All elements are string literals or already-objects with string IDs. + // Sort alphabetically to match the v5 provider's canonical ordering. + sort.Strings(permIDs) + + 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") + body.SetAttributeRaw("permission_groups", hclwrite.TokensForTuple(permObjects)) return } - // Sort IDs alphabetically to match the v5 provider's canonical ordering - sort.Strings(permIDs) + // No string IDs found at depth 1. The list likely contains expression + // references such as data-source lookups. Split the list into individual + // elements and try to transform data source references into for-expressions. + elements := splitPermGroupExprElements(exprTokens) + if len(elements) == 0 { + return + } - // 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)), - }, + // Try to parse each element as a cloudflare_api_token_permission_groups + // data source reference and convert it to a for-expression against the + // v5 replacement data source. + var forExprParts []string + allParsed := true + for _, elem := range elements { + ref := parsePermGroupDataSourceRef(elem) + if ref == nil { + allParsed = false + break + } + // Build: { id = [for pg in data.cloudflare_api_token_permission_groups_list.