diff --git a/cmd/tf-migrate/main.go b/cmd/tf-migrate/main.go index 167bed09..15455bbe 100644 --- a/cmd/tf-migrate/main.go +++ b/cmd/tf-migrate/main.go @@ -1193,9 +1193,13 @@ func applyGlobalPostprocessing(log hclog.Logger, cfg config, outputPaths []strin // Example: cloudflare_tunnel..cname → cloudflare_zero_trust_tunnel_cloudflared..name // This must happen BEFORE resource type renames so the pattern matches the old resource type for _, mapping := range computedAttrMappings { - // Build regex pattern to match old resource type + old attribute - // Pattern: cloudflare_tunnel\.([a-zA-Z0-9_-]+)\.cname - pattern := mapping.OldResourceType + `\.([a-zA-Z0-9_-]+)\.` + mapping.OldAttribute + // Build regex pattern to match old resource type + old attribute. + // Use regexp.QuoteMeta so resource types containing "." (e.g. + // "data.cloudflare_zone") are treated as literals, not wildcards. + // The trailing \b ensures ".zone" does not match the prefix of + // ".zone_id" (which would produce the nonsense attribute "name_id"). + // Pattern example: cloudflare_tunnel\.([a-zA-Z0-9_-]+)\.cname\b + pattern := regexp.QuoteMeta(mapping.OldResourceType) + `\.([a-zA-Z0-9_-]+)\.` + regexp.QuoteMeta(mapping.OldAttribute) + `\b` replacement := mapping.NewResourceType + ".$1." + mapping.NewAttribute newContent := regexReplaceSkippingMovedBlocks(contentStr, pattern, replacement) @@ -1228,9 +1232,10 @@ func applyGlobalPostprocessing(log hclog.Logger, cfg config, outputPaths []strin // Pattern: data.cloudflare_zones..zones → data.cloudflare_zones..result // We need to match: .. for _, rename := range attributeRenames { - // Build regex pattern: data\.cloudflare_zones\.([a-zA-Z0-9_-]+)\.zones - // The instance name can contain letters, numbers, underscores, and hyphens - pattern := rename.ResourceType + `\.([a-zA-Z0-9_-]+)\.` + rename.OldAttribute + // Build regex pattern: data\.cloudflare_zones\.([a-zA-Z0-9_-]+)\.zones\b + // Use regexp.QuoteMeta so resource types with "." are treated as literals. + // The trailing \b prevents clipping prefixes of longer attribute names. + pattern := regexp.QuoteMeta(rename.ResourceType) + `\.([a-zA-Z0-9_-]+)\.` + regexp.QuoteMeta(rename.OldAttribute) + `\b` replacement := rename.ResourceType + ".$1." + rename.NewAttribute newContent := regexReplaceSkippingMovedBlocks(contentStr, pattern, replacement) diff --git a/cmd/tf-migrate/main_postprocessing_test.go b/cmd/tf-migrate/main_postprocessing_test.go index f9ca6545..18af480b 100644 --- a/cmd/tf-migrate/main_postprocessing_test.go +++ b/cmd/tf-migrate/main_postprocessing_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "regexp" "testing" "github.com/hashicorp/go-hclog" @@ -199,3 +200,70 @@ func TestScanForInvalidAttributeReferences(t *testing.T) { } }) } + +// TestComputedAttrMappingBoundary verifies that the computed-attribute rewrite +// regex uses a word boundary so that ".zone" does not corrupt ".zone_id". +// +// Regression: without \b, the zone resource rule +// cloudflare_zone\.([a-zA-Z0-9_-]+)\.zone → cloudflare_zone.$1.name +// would match ".zone_id", yielding ".name_id" which is not a valid attribute. +// +// Also validates that the data.cloudflare_zone zone_id→id rule fires correctly. +func TestComputedAttrMappingBoundary(t *testing.T) { + // Simulate the exact patterns built by applyGlobalPostprocessing after + // the regexp.QuoteMeta + \b fix. + + // Rule 1: cloudflare_zone resource — .zone → .name + resourcePattern := regexp.QuoteMeta("cloudflare_zone") + `\.([a-zA-Z0-9_-]+)\.` + regexp.QuoteMeta("zone") + `\b` + resourceReplacement := "cloudflare_zone.$1.name" + + // Rule 2: data.cloudflare_zone datasource — .zone_id → .id + datasourcePattern := regexp.QuoteMeta("data.cloudflare_zone") + `\.([a-zA-Z0-9_-]+)\.` + regexp.QuoteMeta("zone_id") + `\b` + datasourceReplacement := "data.cloudflare_zone.$1.id" + + t.Run("resource .zone → .name (positive case)", func(t *testing.T) { + input := `locals { domain = cloudflare_zone.minimal.zone }` + got := regexReplaceSkippingMovedBlocks(input, resourcePattern, resourceReplacement) + want := `locals { domain = cloudflare_zone.minimal.name }` + if got != want { + t.Errorf("resource rule\ngot: %s\nwant: %s", got, want) + } + }) + + t.Run("resource rule does NOT mangle data source .zone_id", func(t *testing.T) { + input := `id = "${data.cloudflare_zone.this.zone_id}/security_header"` + got := regexReplaceSkippingMovedBlocks(input, resourcePattern, resourceReplacement) + // Must not produce "name_id"; value should be unchanged by the resource rule. + if got != input { + t.Errorf("resource rule must not touch data.cloudflare_zone.*.zone_id\ngot: %s\nwant: %s", got, input) + } + }) + + t.Run("datasource .zone_id → .id", func(t *testing.T) { + input := `id = "${data.cloudflare_zone.this.zone_id}/security_header"` + got := regexReplaceSkippingMovedBlocks(input, datasourcePattern, datasourceReplacement) + want := `id = "${data.cloudflare_zone.this.id}/security_header"` + if got != want { + t.Errorf("datasource rule\ngot: %s\nwant: %s", got, want) + } + }) + + t.Run("datasource .zone_id → .id in plain assignment", func(t *testing.T) { + input := `output "zone_id" { value = data.cloudflare_zone.by_id.zone_id }` + got := regexReplaceSkippingMovedBlocks(input, datasourcePattern, datasourceReplacement) + want := `output "zone_id" { value = data.cloudflare_zone.by_id.id }` + if got != want { + t.Errorf("datasource rule (plain assignment)\ngot: %s\nwant: %s", got, want) + } + }) + + t.Run("datasource .name is preserved (valid v5 output)", func(t *testing.T) { + input := `locals { zone_name = data.cloudflare_zone.this.name }` + // Apply both rules; neither should touch .name. + got := regexReplaceSkippingMovedBlocks(input, resourcePattern, resourceReplacement) + got = regexReplaceSkippingMovedBlocks(got, datasourcePattern, datasourceReplacement) + if got != input { + t.Errorf("data source .name must not be rewritten\ngot: %s\nwant: %s", got, input) + } + }) +} diff --git a/integration/v4_to_v5/testdata/zone_datasource/expected/zone.tf b/integration/v4_to_v5/testdata/zone_datasource/expected/zone.tf index e8a18cb6..3b49b24b 100644 --- a/integration/v4_to_v5/testdata/zone_datasource/expected/zone.tf +++ b/integration/v4_to_v5/testdata/zone_datasource/expected/zone.tf @@ -99,3 +99,15 @@ data "cloudflare_zone" "from_local" { } } } + +# Scenario 8: cross-file zone_id output reference (tests that .zone_id → .id) +# The zone data source's v4 zone_id output must become .id in v5. +# Regression: a naive .zone → .name rewrite would turn .zone_id into .name_id. +import { + to = cloudflare_zone_setting.cftftest_security_header + id = "${data.cloudflare_zone.by_id.id}/security_header" +} + +output "cftftest_zone_identifier" { + value = data.cloudflare_zone.by_id.id +} diff --git a/integration/v4_to_v5/testdata/zone_datasource/input/zone.tf b/integration/v4_to_v5/testdata/zone_datasource/input/zone.tf index 0fdcf3a7..8b141663 100644 --- a/integration/v4_to_v5/testdata/zone_datasource/input/zone.tf +++ b/integration/v4_to_v5/testdata/zone_datasource/input/zone.tf @@ -81,3 +81,15 @@ data "cloudflare_zone" "from_local" { name = coalesce(local.zone_domain, data.cloudflare_zone.by_id.name) account_id = coalesce(local.cf_account_id, var.cloudflare_account_id) } + +# Scenario 8: cross-file zone_id output reference (tests that .zone_id → .id) +# The zone data source's v4 zone_id output must become .id in v5. +# Regression: a naive .zone → .name rewrite would turn .zone_id into .name_id. +import { + to = cloudflare_zone_setting.cftftest_security_header + id = "${data.cloudflare_zone.by_id.zone_id}/security_header" +} + +output "cftftest_zone_identifier" { + value = data.cloudflare_zone.by_id.zone_id +} diff --git a/internal/datasources/zone/v4_to_v5.go b/internal/datasources/zone/v4_to_v5.go index 84c7a677..f5fa45f9 100644 --- a/internal/datasources/zone/v4_to_v5.go +++ b/internal/datasources/zone/v4_to_v5.go @@ -46,6 +46,21 @@ func (m *V4ToV5Migrator) GetResourceRename() ([]string, string) { return []string{"data.cloudflare_zone"}, "data.cloudflare_zone" } +// GetComputedAttributeMappings implements the ComputedAttributeMapper interface. +// In v4, the zone data source exposed a `zone_id` computed output attribute +// (e.g. data.cloudflare_zone.example.zone_id). In v5, the zone identifier is +// accessed as `id`, so cross-file references must be rewritten accordingly. +func (m *V4ToV5Migrator) GetComputedAttributeMappings() []transform.ComputedAttributeMapping { + return []transform.ComputedAttributeMapping{ + { + OldResourceType: "data.cloudflare_zone", + OldAttribute: "zone_id", + NewResourceType: "data.cloudflare_zone", + NewAttribute: "id", + }, + } +} + // TransformConfig handles configuration file transformations. // Scenarios: // 1. zone_id only → No changes