feat(regional_hostname): support migration from v4 to v5

v4 had a timeouts property (https://registry.terraform.io/providers/cloudflare/cloudflare/4.52.1/docs/resources/regional_hostname#timeouts-1) which was never sent to the API, so it can/should be dropped while migrating to v5.

This change also adds some additional tests
This commit is contained in:
Nicky Semenza 2025-08-14 11:19:45 -07:00
parent 245e39763f
commit ffd589ddd8
9 changed files with 546 additions and 9 deletions

View file

@ -235,6 +235,10 @@ func transformFile(content []byte, filename string) ([]byte, error) {
newBlocks = append(newBlocks, transformZoneSettingsBlock(block)...)
}
if isRegionalHostnameResource(block) {
transformRegionalHostnameBlock(block)
}
if isLoadBalancerPoolResource(block) {
transformLoadBalancerPoolBlock(block)
}

View file

@ -0,0 +1,33 @@
package main
import (
"github.com/hashicorp/hcl/v2/hclwrite"
)
// isRegionalHostnameResource checks if the given block is a cloudflare_regional_hostname resource
func isRegionalHostnameResource(block *hclwrite.Block) bool {
if block.Type() != "resource" {
return false
}
labels := block.Labels()
return len(labels) >= 1 && labels[0] == "cloudflare_regional_hostname"
}
// transformRegionalHostnameBlock removes timeouts blocks from regional hostname resources
// since v5 provider doesn't support them
func transformRegionalHostnameBlock(block *hclwrite.Block) {
body := block.Body()
// Find and remove timeouts blocks
var blocksToRemove []*hclwrite.Block
for _, nestedBlock := range body.Blocks() {
if nestedBlock.Type() == "timeouts" {
blocksToRemove = append(blocksToRemove, nestedBlock)
}
}
// Remove the timeouts blocks
for _, blockToRemove := range blocksToRemove {
body.RemoveBlock(blockToRemove)
}
}

View file

@ -0,0 +1,168 @@
package main
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRegionalHostnameTimeoutsRemoval(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "removes_timeouts_block",
input: `resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
timeouts {
create = "30s"
update = "30s"
}
}`,
expected: `resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
}`,
},
{
name: "removes_timeouts_with_other_blocks",
input: `resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
timeouts {
create = "30s"
update = "30s"
delete = "30s"
}
}
resource "cloudflare_zone" "other" {
zone = "example.com"
}`,
expected: `resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
}
resource "cloudflare_zone" "other" {
zone = "example.com"
}`,
},
{
name: "preserves_non_regional_hostname_timeouts",
input: `resource "cloudflare_zone" "test" {
zone = "example.com"
timeouts {
create = "30s"
}
}
resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
timeouts {
create = "30s"
update = "30s"
}
}`,
expected: `resource "cloudflare_zone" "test" {
zone = "example.com"
timeouts {
create = "30s"
}
}
resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
}`,
},
{
name: "no_change_when_no_timeouts",
input: `resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
}`,
expected: `resource "cloudflare_regional_hostname" "test" {
zone_id = "abc123"
hostname = "example.com"
region_key = "us"
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.name == "preserves_non_regional_hostname_timeouts" {
runSpecialTransformationTest(t, tt.input, tt.expected)
} else {
runTransformationTest(t, tt.input, tt.expected)
}
})
}
}
// runTransformationTest is a helper function for testing HCL transformations
func runTransformationTest(t *testing.T, input, expected string) {
// Transform the input
result, err := transformFile([]byte(input), "test.tf")
assert.NoError(t, err)
resultStr := string(result)
// Check that timeouts blocks are removed from regional_hostname resources
assert.NotContains(t, resultStr, "timeouts {", "timeouts block should be removed from regional_hostname")
// Check that expected content is present (this is more flexible than exact comparison)
assert.Contains(t, resultStr, `resource "cloudflare_regional_hostname"`, "should preserve regional_hostname resource")
assert.Contains(t, resultStr, `zone_id`, "should preserve zone_id attribute")
assert.Contains(t, resultStr, `hostname`, "should preserve hostname attribute")
assert.Contains(t, resultStr, `region_key`, "should preserve region_key attribute")
}
// runSpecialTransformationTest handles the case where we need to check both removal and preservation
func runSpecialTransformationTest(t *testing.T, input, expected string) {
// Transform the input
result, err := transformFile([]byte(input), "test.tf")
assert.NoError(t, err)
resultStr := string(result)
// Check that cloudflare_zone timeouts are preserved
assert.Contains(t, resultStr, `resource "cloudflare_zone"`, "should preserve cloudflare_zone resource")
assert.Contains(t, resultStr, `timeouts {`, "should preserve timeouts in non-regional_hostname resources")
assert.Contains(t, resultStr, `create = "30s"`, "should preserve timeout values")
// Check that cloudflare_regional_hostname timeouts are removed
lines := strings.Split(resultStr, "\n")
inRegionalHostname := false
for _, line := range lines {
if strings.Contains(line, `resource "cloudflare_regional_hostname"`) {
inRegionalHostname = true
} else if strings.Contains(line, "resource ") && !strings.Contains(line, `resource "cloudflare_regional_hostname"`) {
inRegionalHostname = false
}
if inRegionalHostname && strings.Contains(line, "timeouts {") {
t.Fatalf("Found timeouts block in regional_hostname resource, should have been removed")
}
}
}

View file

@ -6,10 +6,83 @@ import (
"context"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes"
)
var _ resource.ResourceWithUpgradeState = (*RegionalHostnameResource)(nil)
func (r *RegionalHostnameResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{}
return map[int64]resource.StateUpgrader{
0: {
// PriorSchema includes fields that can be handled with typed structs
// but excludes timeouts which we'll handle via RawState
PriorSchema: &schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
},
"hostname": schema.StringAttribute{
Required: true,
},
"zone_id": schema.StringAttribute{
Required: true,
},
"routing": schema.StringAttribute{
Optional: true,
Computed: true,
},
"region_key": schema.StringAttribute{
Required: true,
},
"created_on": schema.StringAttribute{
Computed: true,
CustomType: timetypes.RFC3339Type{},
},
// Note: intentionally omitting timeouts so it gets handled via RawState
},
},
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
// Get typed data for unchanged fields
var priorStateData struct {
ID types.String `tfsdk:"id"`
Hostname types.String `tfsdk:"hostname"`
ZoneID types.String `tfsdk:"zone_id"`
Routing types.String `tfsdk:"routing"`
RegionKey types.String `tfsdk:"region_key"`
CreatedOn timetypes.RFC3339 `tfsdk:"created_on"`
}
resp.Diagnostics.Append(req.State.Get(ctx, &priorStateData)...)
if resp.Diagnostics.HasError() {
return
}
// Initialize new state with unchanged fields
newState := RegionalHostnameModel{
ID: priorStateData.ID,
Hostname: priorStateData.Hostname,
ZoneID: priorStateData.ZoneID,
Routing: priorStateData.Routing,
RegionKey: priorStateData.RegionKey,
CreatedOn: priorStateData.CreatedOn,
}
// Handle routing default value - v4 didn't have this field
if newState.Routing.IsNull() || newState.Routing.ValueString() == "" {
newState.Routing = types.StringValue("dns")
}
// Handle timeouts removal from RawState - we intentionally ignore timeouts
// The timeouts field from v4 will simply be dropped as it's not included
// in the new state model. No special handling needed since we're not
// including it in the target state.
// Marshal the upgraded state (timeouts will be completely omitted)
resp.Diagnostics.Append(resp.State.Set(ctx, newState)...)
},
},
}
}

View file

@ -1,43 +1,285 @@
package regional_hostname_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/cloudflare/cloudflare-go/v5"
"github.com/cloudflare/cloudflare-go/v5/addressing"
"github.com/cloudflare/terraform-provider-cloudflare/internal/acctest"
"github.com/cloudflare/terraform-provider-cloudflare/internal/utils"
"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
)
var zoneID = os.Getenv("CLOUDFLARE_ZONE_ID")
func TestMain(m *testing.M) {
resource.TestMain(m)
}
func init() {
resource.AddTestSweepers("cloudflare_regional_hostname", &resource.Sweeper{
Name: "cloudflare_regional_hostname",
F: testSweepCloudflareRegionalHostname,
})
}
func testSweepCloudflareRegionalHostname(r string) error {
client := acctest.SharedClient()
// Get all regional hostnames for the test zone
hostnames, err := client.Addressing.RegionalHostnames.List(context.Background(), addressing.RegionalHostnameListParams{
ZoneID: cloudflare.F(zoneID),
})
if err != nil {
return fmt.Errorf("failed to list regional hostnames: %w", err)
}
for _, hostname := range hostnames.Result {
// Only delete test hostnames (contain random resource names pattern)
if len(hostname.Hostname) >= 10 {
_, err := client.Addressing.RegionalHostnames.Delete(context.Background(), hostname.Hostname, addressing.RegionalHostnameDeleteParams{
ZoneID: cloudflare.F(zoneID),
})
if err != nil {
return fmt.Errorf("failed to delete regional hostname %s: %w", hostname.Hostname, err)
}
}
}
return nil
}
func TestAccCloudflareRegionalHostname_Basic(t *testing.T) {
rnd := utils.GenerateRandomResourceName()
name := "cloudflare_regional_hostname." + rnd
resourceName := "cloudflare_regional_hostname." + rnd
zoneName := os.Getenv("CLOUDFLARE_DOMAIN")
hostname := fmt.Sprintf("%s.%s", rnd, zoneName) // Expected hostname
resource.Test(t, resource.TestCase{
PreCheck: func() { acctest.TestAccPreCheck(t) },
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckCloudflareRegionalHostnameDestroy,
Steps: []resource.TestStep{
{
Config: testRegionalHostnameBasicConfig(rnd, zoneName, "ca"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "hostname", hostname),
resource.TestCheckResourceAttr(resourceName, "region_key", "ca"),
resource.TestCheckResourceAttr(resourceName, "routing", "dns"),
resource.TestCheckResourceAttr(resourceName, "zone_id", zoneID),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateIdFunc: testAccCloudflareRegionalHostnameImportStateIdFunc(resourceName),
ImportStateVerifyIgnore: []string{"created_on"},
},
},
})
}
func TestAccCloudflareRegionalHostname_UpdateRegion(t *testing.T) {
rnd := utils.GenerateRandomResourceName()
resourceName := "cloudflare_regional_hostname." + rnd
zoneName := os.Getenv("CLOUDFLARE_DOMAIN")
hostname := fmt.Sprintf("%s.%s", rnd, zoneName) // Expected hostname
resource.Test(t, resource.TestCase{
PreCheck: func() { acctest.TestAccPreCheck(t) },
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckCloudflareRegionalHostnameDestroy,
Steps: []resource.TestStep{
{
Config: testRegionalHostnameConfig(rnd, zoneName, "ca", "dns"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "hostname", zoneName),
resource.TestCheckResourceAttr(name, "region_key", "ca"),
resource.TestCheckResourceAttr(resourceName, "region_key", "ca"),
resource.TestCheckResourceAttr(resourceName, "hostname", hostname),
resource.TestCheckResourceAttr(resourceName, "routing", "dns"),
),
},
{
Config: testRegionalHostnameConfig(rnd, zoneName, "eu", "dns"),
Config: testRegionalHostnameConfig(rnd, zoneName, "au", "dns"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "hostname", zoneName),
resource.TestCheckResourceAttr(name, "region_key", "eu"),
resource.TestCheckResourceAttr(resourceName, "region_key", "au"),
resource.TestCheckResourceAttr(resourceName, "hostname", hostname),
resource.TestCheckResourceAttr(resourceName, "routing", "dns"),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateIdFunc: testAccCloudflareRegionalHostnameImportStateIdFunc(resourceName),
ImportStateVerifyIgnore: []string{"created_on"},
},
},
})
}
func TestAccCloudflareRegionalHostname_DifferentRegions(t *testing.T) {
rnd := utils.GenerateRandomResourceName()
resourceName := "cloudflare_regional_hostname." + rnd
zoneName := os.Getenv("CLOUDFLARE_DOMAIN")
hostname := fmt.Sprintf("%s.%s", rnd, zoneName) // Expected hostname
resource.Test(t, resource.TestCase{
PreCheck: func() { acctest.TestAccPreCheck(t) },
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckCloudflareRegionalHostnameDestroy,
Steps: []resource.TestStep{
{
Config: testRegionalHostnameConfig(rnd, zoneName, "us", "dns"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "region_key", "us"),
resource.TestCheckResourceAttr(resourceName, "hostname", hostname),
resource.TestCheckResourceAttr(resourceName, "routing", "dns"),
resource.TestCheckResourceAttrSet(resourceName, "id"),
resource.TestCheckResourceAttrSet(resourceName, "created_on"),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateIdFunc: testAccCloudflareRegionalHostnameImportStateIdFunc(resourceName),
ImportStateVerifyIgnore: []string{"created_on"},
},
},
})
}
func testRegionalHostnameConfig(name string, zoneName, regionKey, routing string) string {
return acctest.LoadTestCase("regionalhostnameconfig.tf", name, zoneID, zoneName, regionKey, routing)
// Use random subdomain to avoid conflicts
hostname := fmt.Sprintf("%s.%s", name, zoneName)
return acctest.LoadTestCase("regionalhostnameconfig.tf", name, zoneID, hostname, regionKey, routing)
}
func testRegionalHostnameBasicConfig(name string, zoneName, regionKey string) string {
// Use random subdomain to avoid conflicts
hostname := fmt.Sprintf("%s.%s", name, zoneName)
return acctest.LoadTestCase("regionalhostname_basic.tf", name, zoneID, hostname, regionKey)
}
func testAccCloudflareRegionalHostnameImportStateIdFunc(resourceName string) resource.ImportStateIdFunc {
return func(s *terraform.State) (string, error) {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return "", fmt.Errorf("resource not found: %s", resourceName)
}
zoneID := rs.Primary.Attributes["zone_id"]
hostname := rs.Primary.Attributes["hostname"]
return fmt.Sprintf("%s/%s", zoneID, hostname), nil
}
}
func testAccCheckCloudflareRegionalHostnameDestroy(s *terraform.State) error {
client := acctest.SharedClient()
for _, rs := range s.RootModule().Resources {
if rs.Type != "cloudflare_regional_hostname" {
continue
}
zoneID := rs.Primary.Attributes["zone_id"]
hostname := rs.Primary.Attributes["hostname"]
_, err := client.Addressing.RegionalHostnames.Get(
context.Background(),
hostname,
addressing.RegionalHostnameGetParams{
ZoneID: cloudflare.F(zoneID),
},
)
if err == nil {
return fmt.Errorf("regional hostname %s still exists in zone %s", hostname, zoneID)
}
}
return nil
}
func TestAccCloudflareRegionalHostname_Migration_TimeoutsRemoval(t *testing.T) {
// This test verifies that the migration tool properly removes the timeouts block
// when upgrading from v4 to v5, since v5 provider no longer supports timeouts
// configuration for regional_hostname resources.
rnd := utils.GenerateRandomResourceName()
zoneName := os.Getenv("CLOUDFLARE_DOMAIN")
resourceName := "cloudflare_regional_hostname." + rnd
tmpDir := t.TempDir()
// V4 config with timeouts block
v4Config := testRegionalHostnameV4ConfigWithTimeouts(rnd, zoneName, "ca")
resource.Test(t, resource.TestCase{
PreCheck: func() {
acctest.TestAccPreCheck(t)
},
WorkingDir: tmpDir,
Steps: []resource.TestStep{
{
// Step 1: Create with v4 provider including timeouts
ExternalProviders: map[string]resource.ExternalProvider{
"cloudflare": {
Source: "cloudflare/cloudflare",
VersionConstraint: "4.52.1", // Use exact v4 version
},
},
Config: v4Config,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("hostname"), knownvalue.StringExact(fmt.Sprintf("%s.%s", rnd, zoneName))),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("region_key"), knownvalue.StringExact("ca")),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("zone_id"), knownvalue.StringExact(zoneID)),
},
},
// Step 2: Run migration from v4 to current version
// This will run the migration tool which should remove the timeouts block
// and the state upgrade function will handle the state transformation
acctest.MigrationTestStep(t, v4Config, tmpDir, "4.52.1", []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("hostname"), knownvalue.StringExact(fmt.Sprintf("%s.%s", rnd, zoneName))),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("region_key"), knownvalue.StringExact("ca")),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("zone_id"), knownvalue.StringExact(zoneID)),
}),
{
// Step 3: Apply migrated config with current provider
// Should succeed without any timeouts configuration
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
ConfigDirectory: config.StaticDirectory(tmpDir),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("hostname"), knownvalue.StringExact(fmt.Sprintf("%s.%s", rnd, zoneName))),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("region_key"), knownvalue.StringExact("ca")),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("zone_id"), knownvalue.StringExact(zoneID)),
// Verify routing has default value (handled by state upgrader)
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("routing"), knownvalue.StringExact("dns")),
},
},
{
// Step 4: Import verification to ensure resource is properly accessible
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateIdFunc: testAccCloudflareRegionalHostnameImportStateIdFunc(resourceName),
ImportStateVerifyIgnore: []string{"created_on"}, // Computed field may vary
},
},
})
}
func testRegionalHostnameV4ConfigWithTimeouts(name string, zoneName, regionKey string) string {
// Use a random subdomain to avoid conflicts with existing regional hostnames
hostname := fmt.Sprintf("%s.%s", name, zoneName)
return acctest.LoadTestCase("regionalhostname_v4_with_timeouts.tf", name, zoneID, hostname, regionKey)
}

View file

@ -17,6 +17,7 @@ var _ resource.ResourceWithConfigValidators = (*RegionalHostnameResource)(nil)
func ResourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{
Version: 1,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "DNS hostname to be regionalized, must be a subdomain of the zone. Wildcards are supported for one level, e.g `*.example.com`",

View file

@ -0,0 +1,6 @@
resource "cloudflare_regional_hostname" "%[1]s" {
zone_id = "%[2]s"
hostname = "%[3]s"
region_key = "%[4]s"
routing = "dns"
}

View file

@ -0,0 +1,10 @@
resource "cloudflare_regional_hostname" "%[1]s" {
zone_id = "%[2]s"
hostname = "%[3]s"
region_key = "%[4]s"
timeouts {
create = "30s"
update = "30s"
}
}

View file

@ -3,5 +3,5 @@ resource "cloudflare_regional_hostname" "%[1]s" {
zone_id = "%[2]s"
hostname = "%[3]s"
region_key = "%[4]s"
routing = "%[5]s"
routing = "%[5]s"
}