Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions cmd/tf-migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1193,9 +1193,13 @@ func applyGlobalPostprocessing(log hclog.Logger, cfg config, outputPaths []strin
// Example: cloudflare_tunnel.<name>.cname → cloudflare_zero_trust_tunnel_cloudflared.<name>.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)

Expand Down Expand Up @@ -1228,9 +1232,10 @@ func applyGlobalPostprocessing(log hclog.Logger, cfg config, outputPaths []strin
// Pattern: data.cloudflare_zones.<instance_name>.zones → data.cloudflare_zones.<instance_name>.result
// We need to match: <ResourceType>.<instance_name>.<OldAttribute>
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)

Expand Down
68 changes: 68 additions & 0 deletions cmd/tf-migrate/main_postprocessing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"regexp"
"testing"

"github.com/hashicorp/go-hclog"
Expand Down Expand Up @@ -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)
}
})
}
12 changes: 12 additions & 0 deletions integration/v4_to_v5/testdata/zone_datasource/expected/zone.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions integration/v4_to_v5/testdata/zone_datasource/input/zone.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 15 additions & 0 deletions internal/datasources/zone/v4_to_v5.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down