diff --git a/integration/v4_to_v5/testdata/snippet_rules/expected/snippet_rules.tf b/integration/v4_to_v5/testdata/snippet_rules/expected/snippet_rules.tf new file mode 100644 index 0000000..40a093b --- /dev/null +++ b/integration/v4_to_v5/testdata/snippet_rules/expected/snippet_rules.tf @@ -0,0 +1,31 @@ +resource "cloudflare_snippet_rules" "basic" { + zone_id = "test-zone-id" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/api\"" + snippet_name = "basic_snippet" + description = "Basic test rule" + } + ] +} + +resource "cloudflare_snippet_rules" "multiple_rules" { + zone_id = "test-zone-id-2" + + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/v1\"" + snippet_name = "v1_snippet" + }, + { + enabled = false + expression = "http.request.uri.path eq \"/v2\"" + snippet_name = "v2_snippet" + description = "Version 2 API" + } + ] +} diff --git a/integration/v4_to_v5/testdata/snippet_rules/expected/snippet_rules_e2e.tf b/integration/v4_to_v5/testdata/snippet_rules/expected/snippet_rules_e2e.tf new file mode 100644 index 0000000..780569c --- /dev/null +++ b/integration/v4_to_v5/testdata/snippet_rules/expected/snippet_rules_e2e.tf @@ -0,0 +1,330 @@ +# Comprehensive Integration Tests for snippet_rules Migration +# This file tests all Terraform patterns and edge cases + +# Test 1: Basic resource with single rule +resource "cloudflare_snippet_rules" "basic" { + zone_id = "zone-basic-123" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/api\"" + snippet_name = "basic_snippet" + description = "Basic test rule" + } + ] +} + +# Test 2: Multiple rules in one resource +resource "cloudflare_snippet_rules" "multiple_rules" { + zone_id = "zone-multi-456" + + + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/v1\"" + snippet_name = "v1_snippet" + description = "Version 1 API" + }, + { + enabled = false + expression = "http.request.uri.path eq \"/v2\"" + snippet_name = "v2_snippet" + description = "Version 2 API" + }, + { + expression = "http.request.uri.path eq \"/v3\"" + snippet_name = "v3_snippet" + enabled = true + } + ] +} + +# Test 3: Using variables +variable "zone_id" { + type = string + default = "zone-var-789" +} + +variable "snippet_name" { + type = string + default = "var_snippet" +} + +resource "cloudflare_snippet_rules" "with_variables" { + zone_id = var.zone_id + + rules = [ + { + enabled = var.enable_snippet + expression = "http.request.uri.path eq \"/var\"" + snippet_name = var.snippet_name + description = "Rule with variables" + } + ] +} + +# Test 4: for_each with map +resource "cloudflare_snippet_rules" "for_each_map" { + for_each = { + prod = "zone-prod-001" + staging = "zone-staging-002" + dev = "zone-dev-003" + } + + zone_id = each.value + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/${each.key}\"" + snippet_name = "${each.key}_snippet" + description = "Rule for ${each.key} environment" + } + ] +} + +# Test 5: for_each with set +resource "cloudflare_snippet_rules" "for_each_set" { + for_each = toset(["alpha", "beta", "gamma"]) + + zone_id = "zone-set-${each.key}" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/${each.key}\"" + snippet_name = "${each.key}_snippet" + } + ] +} + +# Test 6: count-based resources +resource "cloudflare_snippet_rules" "count_based" { + count = 3 + + zone_id = "zone-count-${count.index}" + + rules = [ + { + enabled = count.index == 0 + expression = "http.request.uri.path eq \"/count/${count.index}\"" + snippet_name = "count_${count.index}_snippet" + description = "Count-based rule ${count.index}" + } + ] +} + +# Test 7: Conditional creation +resource "cloudflare_snippet_rules" "conditional" { + count = var.create_conditional ? 1 : 0 + + zone_id = "zone-conditional-999" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/conditional\"" + snippet_name = "conditional_snippet" + } + ] +} + +# Test 8: Complex expressions +resource "cloudflare_snippet_rules" "complex_expressions" { + zone_id = "zone-complex-111" + + + rules = [ + { + enabled = true + expression = "(http.request.uri.path eq \"/api\") and (http.request.method eq \"POST\")" + snippet_name = "complex_snippet_1" + description = "Complex expression with AND" + }, + { + enabled = true + expression = "(http.host eq \"example.com\") or (http.host eq \"www.example.com\")" + snippet_name = "complex_snippet_2" + description = "Complex expression with OR" + } + ] +} + +# Test 9: Cross-resource reference +resource "cloudflare_snippet_rules" "referenced" { + zone_id = cloudflare_snippet_rules.basic.zone_id + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/referenced\"" + snippet_name = "ref_snippet" + description = "References another snippet_rules resource" + } + ] +} + +# Test 10: Dynamic rules using locals +locals { + environments = ["dev", "test", "prod"] + base_zone_id = "zone-local-base" +} + +resource "cloudflare_snippet_rules" "with_locals" { + zone_id = local.base_zone_id + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/local\"" + snippet_name = "local_snippet" + description = "Using locals: ${join(", ", local.environments)}" + } + ] +} + +# Test 11: Rules with special characters in expressions +resource "cloudflare_snippet_rules" "special_chars" { + zone_id = "zone-special-222" + + + rules = [ + { + enabled = true + expression = "http.request.uri.path matches \"^/api/v[0-9]+/.*$\"" + snippet_name = "regex_snippet" + description = "Rule with regex pattern" + }, + { + enabled = true + expression = "http.request.uri.query contains \"token=\"" + snippet_name = "query_snippet" + description = "Rule checking query parameters" + } + ] +} + +# Test 12: Minimal rules (only required fields) +resource "cloudflare_snippet_rules" "minimal" { + zone_id = "zone-minimal-333" + + rules = [ + { + expression = "http.request.uri.path eq \"/minimal\"" + snippet_name = "minimal_snippet" + enabled = true + } + ] +} + +# Test 13: All optional fields populated +resource "cloudflare_snippet_rules" "all_fields" { + zone_id = "zone-all-444" + + rules = [ + { + enabled = false + expression = "http.request.uri.path eq \"/all\"" + snippet_name = "all_fields_snippet" + description = "Rule with all optional fields set" + } + ] +} + +# Test 14: Mixed enabled states +resource "cloudflare_snippet_rules" "mixed_enabled" { + zone_id = "zone-mixed-555" + + + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/enabled\"" + snippet_name = "enabled_snippet" + }, + { + enabled = false + expression = "http.request.uri.path eq \"/disabled\"" + snippet_name = "disabled_snippet" + }, + { + expression = "http.request.uri.path eq \"/default\"" + snippet_name = "default_snippet" + enabled = true + } + ] +} + +# Test 15: Resource with depends_on +resource "cloudflare_snippet_rules" "depends" { + depends_on = [cloudflare_snippet_rules.basic] + + zone_id = "zone-depends-666" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/depends\"" + snippet_name = "depends_snippet" + } + ] +} + +# Test 16: Using terraform functions +resource "cloudflare_snippet_rules" "with_functions" { + zone_id = "zone-func-777" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/${lower("UPPERCASE")}\"" + snippet_name = "${format("func_%s_snippet", "test")}" + description = "${title("function-based description")}" + } + ] +} + +# Test 17: Multiple rules with various patterns +resource "cloudflare_snippet_rules" "comprehensive" { + zone_id = "zone-comprehensive-888" + + + + + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/rule1\"" + snippet_name = "rule1_snippet" + description = "First rule" + }, + { + enabled = true + expression = "http.request.uri.path eq \"/rule2\"" + snippet_name = "rule2_snippet" + description = "Second rule" + }, + { + enabled = false + expression = "http.request.uri.path eq \"/rule3\"" + snippet_name = "rule3_snippet" + description = "Third rule" + }, + { + enabled = true + expression = "http.request.uri.path eq \"/rule4\"" + snippet_name = "rule4_snippet" + }, + { + expression = "http.request.uri.path eq \"/rule5\"" + snippet_name = "rule5_snippet" + description = "Fifth rule without explicit enabled" + enabled = true + } + ] +} diff --git a/integration/v4_to_v5/testdata/snippet_rules/expected/terraform.tfstate b/integration/v4_to_v5/testdata/snippet_rules/expected/terraform.tfstate new file mode 100644 index 0000000..7c82b90 --- /dev/null +++ b/integration/v4_to_v5/testdata/snippet_rules/expected/terraform.tfstate @@ -0,0 +1,58 @@ +{ + "version": 4, + "terraform_version": "1.5.0", + "serial": 1, + "lineage": "test-lineage", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "cloudflare_snippet_rules", + "name": "basic", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "zone_id": "test-zone-id", + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/api\"", + "snippet_name": "basic_snippet", + "description": "Basic test rule" + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_snippet_rules", + "name": "multiple_rules", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "zone_id": "test-zone-id-2", + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/v1\"", + "snippet_name": "v1_snippet" + }, + { + "enabled": false, + "expression": "http.request.uri.path eq \"/v2\"", + "snippet_name": "v2_snippet", + "description": "Version 2 API" + } + ] + } + } + ] + } + ] +} diff --git a/integration/v4_to_v5/testdata/snippet_rules/input/snippet_rules.tf b/integration/v4_to_v5/testdata/snippet_rules/input/snippet_rules.tf new file mode 100644 index 0000000..4d2b1a7 --- /dev/null +++ b/integration/v4_to_v5/testdata/snippet_rules/input/snippet_rules.tf @@ -0,0 +1,27 @@ +resource "cloudflare_snippet_rules" "basic" { + zone_id = "test-zone-id" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/api\"" + snippet_name = "basic_snippet" + description = "Basic test rule" + } +} + +resource "cloudflare_snippet_rules" "multiple_rules" { + zone_id = "test-zone-id-2" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/v1\"" + snippet_name = "v1_snippet" + } + + rules { + enabled = false + expression = "http.request.uri.path eq \"/v2\"" + snippet_name = "v2_snippet" + description = "Version 2 API" + } +} diff --git a/integration/v4_to_v5/testdata/snippet_rules/input/snippet_rules_e2e.tf b/integration/v4_to_v5/testdata/snippet_rules/input/snippet_rules_e2e.tf new file mode 100644 index 0000000..6fe5357 --- /dev/null +++ b/integration/v4_to_v5/testdata/snippet_rules/input/snippet_rules_e2e.tf @@ -0,0 +1,293 @@ +# Comprehensive Integration Tests for snippet_rules Migration +# This file tests all Terraform patterns and edge cases + +# Test 1: Basic resource with single rule +resource "cloudflare_snippet_rules" "basic" { + zone_id = "zone-basic-123" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/api\"" + snippet_name = "basic_snippet" + description = "Basic test rule" + } +} + +# Test 2: Multiple rules in one resource +resource "cloudflare_snippet_rules" "multiple_rules" { + zone_id = "zone-multi-456" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/v1\"" + snippet_name = "v1_snippet" + description = "Version 1 API" + } + + rules { + enabled = false + expression = "http.request.uri.path eq \"/v2\"" + snippet_name = "v2_snippet" + description = "Version 2 API" + } + + rules { + expression = "http.request.uri.path eq \"/v3\"" + snippet_name = "v3_snippet" + } +} + +# Test 3: Using variables +variable "zone_id" { + type = string + default = "zone-var-789" +} + +variable "snippet_name" { + type = string + default = "var_snippet" +} + +resource "cloudflare_snippet_rules" "with_variables" { + zone_id = var.zone_id + + rules { + enabled = var.enable_snippet + expression = "http.request.uri.path eq \"/var\"" + snippet_name = var.snippet_name + description = "Rule with variables" + } +} + +# Test 4: for_each with map +resource "cloudflare_snippet_rules" "for_each_map" { + for_each = { + prod = "zone-prod-001" + staging = "zone-staging-002" + dev = "zone-dev-003" + } + + zone_id = each.value + + rules { + enabled = true + expression = "http.request.uri.path eq \"/${each.key}\"" + snippet_name = "${each.key}_snippet" + description = "Rule for ${each.key} environment" + } +} + +# Test 5: for_each with set +resource "cloudflare_snippet_rules" "for_each_set" { + for_each = toset(["alpha", "beta", "gamma"]) + + zone_id = "zone-set-${each.key}" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/${each.key}\"" + snippet_name = "${each.key}_snippet" + } +} + +# Test 6: count-based resources +resource "cloudflare_snippet_rules" "count_based" { + count = 3 + + zone_id = "zone-count-${count.index}" + + rules { + enabled = count.index == 0 + expression = "http.request.uri.path eq \"/count/${count.index}\"" + snippet_name = "count_${count.index}_snippet" + description = "Count-based rule ${count.index}" + } +} + +# Test 7: Conditional creation +resource "cloudflare_snippet_rules" "conditional" { + count = var.create_conditional ? 1 : 0 + + zone_id = "zone-conditional-999" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/conditional\"" + snippet_name = "conditional_snippet" + } +} + +# Test 8: Complex expressions +resource "cloudflare_snippet_rules" "complex_expressions" { + zone_id = "zone-complex-111" + + rules { + enabled = true + expression = "(http.request.uri.path eq \"/api\") and (http.request.method eq \"POST\")" + snippet_name = "complex_snippet_1" + description = "Complex expression with AND" + } + + rules { + enabled = true + expression = "(http.host eq \"example.com\") or (http.host eq \"www.example.com\")" + snippet_name = "complex_snippet_2" + description = "Complex expression with OR" + } +} + +# Test 9: Cross-resource reference +resource "cloudflare_snippet_rules" "referenced" { + zone_id = cloudflare_snippet_rules.basic.zone_id + + rules { + enabled = true + expression = "http.request.uri.path eq \"/referenced\"" + snippet_name = "ref_snippet" + description = "References another snippet_rules resource" + } +} + +# Test 10: Dynamic rules using locals +locals { + environments = ["dev", "test", "prod"] + base_zone_id = "zone-local-base" +} + +resource "cloudflare_snippet_rules" "with_locals" { + zone_id = local.base_zone_id + + rules { + enabled = true + expression = "http.request.uri.path eq \"/local\"" + snippet_name = "local_snippet" + description = "Using locals: ${join(", ", local.environments)}" + } +} + +# Test 11: Rules with special characters in expressions +resource "cloudflare_snippet_rules" "special_chars" { + zone_id = "zone-special-222" + + rules { + enabled = true + expression = "http.request.uri.path matches \"^/api/v[0-9]+/.*$\"" + snippet_name = "regex_snippet" + description = "Rule with regex pattern" + } + + rules { + enabled = true + expression = "http.request.uri.query contains \"token=\"" + snippet_name = "query_snippet" + description = "Rule checking query parameters" + } +} + +# Test 12: Minimal rules (only required fields) +resource "cloudflare_snippet_rules" "minimal" { + zone_id = "zone-minimal-333" + + rules { + expression = "http.request.uri.path eq \"/minimal\"" + snippet_name = "minimal_snippet" + } +} + +# Test 13: All optional fields populated +resource "cloudflare_snippet_rules" "all_fields" { + zone_id = "zone-all-444" + + rules { + enabled = false + expression = "http.request.uri.path eq \"/all\"" + snippet_name = "all_fields_snippet" + description = "Rule with all optional fields set" + } +} + +# Test 14: Mixed enabled states +resource "cloudflare_snippet_rules" "mixed_enabled" { + zone_id = "zone-mixed-555" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/enabled\"" + snippet_name = "enabled_snippet" + } + + rules { + enabled = false + expression = "http.request.uri.path eq \"/disabled\"" + snippet_name = "disabled_snippet" + } + + rules { + # Implicit enabled (uses default) + expression = "http.request.uri.path eq \"/default\"" + snippet_name = "default_snippet" + } +} + +# Test 15: Resource with depends_on +resource "cloudflare_snippet_rules" "depends" { + depends_on = [cloudflare_snippet_rules.basic] + + zone_id = "zone-depends-666" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/depends\"" + snippet_name = "depends_snippet" + } +} + +# Test 16: Using terraform functions +resource "cloudflare_snippet_rules" "with_functions" { + zone_id = "zone-func-777" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/${lower("UPPERCASE")}\"" + snippet_name = "${format("func_%s_snippet", "test")}" + description = "${title("function-based description")}" + } +} + +# Test 17: Multiple rules with various patterns +resource "cloudflare_snippet_rules" "comprehensive" { + zone_id = "zone-comprehensive-888" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/rule1\"" + snippet_name = "rule1_snippet" + description = "First rule" + } + + rules { + enabled = true + expression = "http.request.uri.path eq \"/rule2\"" + snippet_name = "rule2_snippet" + description = "Second rule" + } + + rules { + enabled = false + expression = "http.request.uri.path eq \"/rule3\"" + snippet_name = "rule3_snippet" + description = "Third rule" + } + + rules { + enabled = true + expression = "http.request.uri.path eq \"/rule4\"" + snippet_name = "rule4_snippet" + } + + rules { + expression = "http.request.uri.path eq \"/rule5\"" + snippet_name = "rule5_snippet" + description = "Fifth rule without explicit enabled" + } +} diff --git a/integration/v4_to_v5/testdata/snippet_rules/input/terraform.tfstate b/integration/v4_to_v5/testdata/snippet_rules/input/terraform.tfstate new file mode 100644 index 0000000..e05a3b7 --- /dev/null +++ b/integration/v4_to_v5/testdata/snippet_rules/input/terraform.tfstate @@ -0,0 +1,58 @@ +{ + "lineage": "test-lineage", + "outputs": {}, + "resources": [ + { + "instances": [ + { + "attributes": { + "rules": [ + { + "description": "Basic test rule", + "enabled": true, + "expression": "http.request.uri.path eq \"/api\"", + "snippet_name": "basic_snippet" + } + ], + "zone_id": "test-zone-id" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "basic", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_snippet_rules" + }, + { + "instances": [ + { + "attributes": { + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/v1\"", + "snippet_name": "v1_snippet" + }, + { + "description": "Version 2 API", + "enabled": false, + "expression": "http.request.uri.path eq \"/v2\"", + "snippet_name": "v2_snippet" + } + ], + "zone_id": "test-zone-id-2" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "multiple_rules", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_snippet_rules" + } + ], + "serial": 1, + "terraform_version": "1.5.0", + "version": 4 +} \ No newline at end of file diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 84e4f81..70e915f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -24,6 +24,7 @@ import ( "github.com/cloudflare/tf-migrate/internal/resources/r2_bucket" "github.com/cloudflare/tf-migrate/internal/resources/regional_hostname" "github.com/cloudflare/tf-migrate/internal/resources/snippet" + "github.com/cloudflare/tf-migrate/internal/resources/snippet_rules" "github.com/cloudflare/tf-migrate/internal/resources/spectrum_application" "github.com/cloudflare/tf-migrate/internal/resources/tiered_cache" "github.com/cloudflare/tf-migrate/internal/resources/url_normalization_settings" @@ -82,6 +83,7 @@ func RegisterAllMigrations() { r2_bucket.NewV4ToV5Migrator() regional_hostname.NewV4ToV5Migrator() snippet.NewV4ToV5Migrator() + snippet_rules.NewV4ToV5Migrator() tiered_cache.NewV4ToV5Migrator() spectrum_application.NewV4ToV5Migrator() url_normalization_settings.NewV4ToV5Migrator() diff --git a/internal/resources/snippet_rules/v4_to_v5.go b/internal/resources/snippet_rules/v4_to_v5.go new file mode 100644 index 0000000..d7ceb30 --- /dev/null +++ b/internal/resources/snippet_rules/v4_to_v5.go @@ -0,0 +1,95 @@ +package snippet_rules + +import ( + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "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 { +} + +func NewV4ToV5Migrator() transform.ResourceTransformer { + migrator := &V4ToV5Migrator{} + // Register with the v4 resource name (same as v5) + internal.RegisterMigrator("cloudflare_snippet_rules", "v4", "v5", migrator) + return migrator +} + +func (m *V4ToV5Migrator) GetResourceType() string { + // Resource name doesn't change + return "cloudflare_snippet_rules" +} + +func (m *V4ToV5Migrator) CanHandle(resourceType string) bool { + return resourceType == "cloudflare_snippet_rules" +} + +// 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 "cloudflare_snippet_rules", "cloudflare_snippet_rules" +} + +// Preprocess performs any string-level transformations before HCL parsing. +// For snippet_rules, no preprocessing is needed. +func (m *V4ToV5Migrator) Preprocess(content string) string { + return content +} + +// TransformConfig transforms the HCL configuration from v4 to v5. +// Main transformation: Convert rules blocks to attribute array +// CRITICAL: v4 default for enabled was true, v5 default is false +// We must explicitly set enabled = true when missing to preserve v4 behavior +func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite.Block) (*transform.TransformResult, error) { + body := block.Body() + + // Convert rules blocks to attribute array with preprocessing to handle defaults + // v4: rules { enabled = true ... } OR rules { ... } (enabled defaults to true in v4) + // v5: rules = [{ enabled = true ... }] + tfhcl.ConvertBlocksToAttributeList(body, "rules", func(ruleBlock *hclwrite.Block) { + ruleBody := ruleBlock.Body() + // If enabled field is missing, add it with v4's default value (true) + // This prevents drift since v5's default is false + tfhcl.EnsureAttribute(ruleBody, "enabled", true) + }) + + return &transform.TransformResult{ + Blocks: []*hclwrite.Block{block}, + RemoveOriginal: false, + }, nil +} + +// TransformState transforms the JSON state from v4 to v5. +// Rules structure remains the same (already an array), but we must: +// 1. Set schema_version to 0 +// 2. NOT add computed fields (id, last_updated) +// 3. Handle empty arrays correctly +func (m *V4ToV5Migrator) TransformState(ctx *transform.Context, stateJSON gjson.Result, resourcePath, resourceName string) (string, error) { + result := stateJSON.String() + + // Get attributes + attrs := stateJSON.Get("attributes") + if !attrs.Exists() { + // Even for invalid instances, set schema_version + result, _ = sjson.Set(result, "schema_version", 0) + return result, nil + } + + // Handle empty rules array - delete it rather than keeping [] + rulesField := attrs.Get("rules") + if rulesField.Exists() && rulesField.IsArray() { + if len(rulesField.Array()) == 0 { + result, _ = sjson.Delete(result, "attributes.rules") + } + } + + // Set schema_version to 0 for v5 + result, _ = sjson.Set(result, "schema_version", 0) + + return result, nil +} diff --git a/internal/resources/snippet_rules/v4_to_v5_test.go b/internal/resources/snippet_rules/v4_to_v5_test.go new file mode 100644 index 0000000..fc62938 --- /dev/null +++ b/internal/resources/snippet_rules/v4_to_v5_test.go @@ -0,0 +1,374 @@ +package snippet_rules + +import ( + "testing" + + "github.com/cloudflare/tf-migrate/internal/testhelpers" +) + +func TestConfigTransformation(t *testing.T) { + migrator := NewV4ToV5Migrator() + + testCases := []testhelpers.ConfigTestCase{ + { + Name: "single rule with all fields", + Input: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = "zone123" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/test\"" + snippet_name = "example_snippet" + description = "Test rule" + } +}`, + Expected: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = "zone123" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/test\"" + snippet_name = "example_snippet" + description = "Test rule" + } + ] +}`, + }, + { + Name: "multiple rules", + Input: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = "zone123" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/api\"" + snippet_name = "api_snippet" + } + + rules { + enabled = false + expression = "http.request.uri.path eq \"/admin\"" + snippet_name = "admin_snippet" + description = "Admin rule" + } +}`, + Expected: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = "zone123" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/api\"" + snippet_name = "api_snippet" + }, + { + enabled = false + expression = "http.request.uri.path eq \"/admin\"" + snippet_name = "admin_snippet" + description = "Admin rule" + } + ] +}`, + }, + { + Name: "rule with minimal fields", + Input: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = "zone123" + + rules { + expression = "http.request.uri.path eq \"/test\"" + snippet_name = "test_snippet" + } +}`, + Expected: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = "zone123" + + rules = [ + { + expression = "http.request.uri.path eq \"/test\"" + snippet_name = "test_snippet" + enabled = true + } + ] +}`, + }, + } + + testhelpers.RunConfigTransformTests(t, testCases, migrator) +} + +func TestConfigTransformation_MultipleResources(t *testing.T) { + migrator := NewV4ToV5Migrator() + + testCases := []testhelpers.ConfigTestCase{ + { + Name: "multiple snippet_rules resources in one file", + Input: ` +resource "cloudflare_snippet_rules" "first" { + zone_id = "zone123" + + rules { + enabled = true + expression = "http.request.uri.path eq \"/first\"" + snippet_name = "first_snippet" + } +} + +resource "cloudflare_snippet_rules" "second" { + zone_id = "zone456" + + rules { + enabled = false + expression = "http.request.uri.path eq \"/second\"" + snippet_name = "second_snippet" + } +}`, + Expected: ` +resource "cloudflare_snippet_rules" "first" { + zone_id = "zone123" + + rules = [ + { + enabled = true + expression = "http.request.uri.path eq \"/first\"" + snippet_name = "first_snippet" + } + ] +} + +resource "cloudflare_snippet_rules" "second" { + zone_id = "zone456" + + rules = [ + { + enabled = false + expression = "http.request.uri.path eq \"/second\"" + snippet_name = "second_snippet" + } + ] +}`, + }, + } + + testhelpers.RunConfigTransformTests(t, testCases, migrator) +} + +func TestConfigTransformation_WithVariables(t *testing.T) { + migrator := NewV4ToV5Migrator() + + testCases := []testhelpers.ConfigTestCase{ + { + Name: "rules with variable references", + Input: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = var.zone_id + + rules { + enabled = var.enable_rule + expression = var.rule_expression + snippet_name = var.snippet_name + description = var.description + } +}`, + Expected: ` +resource "cloudflare_snippet_rules" "example" { + zone_id = var.zone_id + + rules = [ + { + enabled = var.enable_rule + expression = var.rule_expression + snippet_name = var.snippet_name + description = var.description + } + ] +}`, + }, + } + + testhelpers.RunConfigTransformTests(t, testCases, migrator) +} + +func TestStateTransformation(t *testing.T) { + migrator := NewV4ToV5Migrator() + + testCases := []testhelpers.StateTestCase{ + { + Name: "single rule with all fields", + Input: `{ + "schema_version": 1, + "attributes": { + "zone_id": "zone123", + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/test\"", + "snippet_name": "example_snippet", + "description": "Test rule" + } + ] + } +}`, + Expected: `{ + "schema_version": 0, + "attributes": { + "zone_id": "zone123", + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/test\"", + "snippet_name": "example_snippet", + "description": "Test rule" + } + ] + } +}`, + }, + { + Name: "multiple rules", + Input: `{ + "schema_version": 1, + "attributes": { + "zone_id": "zone123", + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/api\"", + "snippet_name": "api_snippet" + }, + { + "enabled": false, + "expression": "http.request.uri.path eq \"/admin\"", + "snippet_name": "admin_snippet", + "description": "Admin rule" + } + ] + } +}`, + Expected: `{ + "schema_version": 0, + "attributes": { + "zone_id": "zone123", + "rules": [ + { + "enabled": true, + "expression": "http.request.uri.path eq \"/api\"", + "snippet_name": "api_snippet" + }, + { + "enabled": false, + "expression": "http.request.uri.path eq \"/admin\"", + "snippet_name": "admin_snippet", + "description": "Admin rule" + } + ] + } +}`, + }, + { + Name: "empty rules array", + Input: `{ + "schema_version": 1, + "attributes": { + "zone_id": "zone123", + "rules": [] + } +}`, + Expected: `{ + "schema_version": 0, + "attributes": { + "zone_id": "zone123" + } +}`, + }, + { + Name: "minimal state without rules", + Input: `{ + "schema_version": 1, + "attributes": { + "zone_id": "zone123" + } +}`, + Expected: `{ + "schema_version": 0, + "attributes": { + "zone_id": "zone123" + } +}`, + }, + } + + testhelpers.RunStateTransformTests(t, testCases, migrator) +} + +func TestStateTransformation_EdgeCases(t *testing.T) { + migrator := NewV4ToV5Migrator() + + testCases := []testhelpers.StateTestCase{ + { + Name: "state without attributes (invalid instance)", + Input: `{ + "schema_version": 1 +}`, + Expected: `{ + "schema_version": 0 +}`, + }, + { + Name: "state with null rules", + Input: `{ + "schema_version": 1, + "attributes": { + "zone_id": "zone123", + "rules": null + } +}`, + Expected: `{ + "schema_version": 0, + "attributes": { + "zone_id": "zone123", + "rules": null + } +}`, + }, + } + + testhelpers.RunStateTransformTests(t, testCases, migrator) +} + +func TestMigratorMethods(t *testing.T) { + migrator := NewV4ToV5Migrator() + + // Test CanHandle + if !migrator.CanHandle("cloudflare_snippet_rules") { + t.Error("CanHandle() should return true for cloudflare_snippet_rules") + } + if migrator.CanHandle("cloudflare_other_resource") { + t.Error("CanHandle() should return false for other resources") + } + + // Test Preprocess (should return input unchanged) + input := "some content" + if output := migrator.Preprocess(input); output != input { + t.Errorf("Preprocess() modified content: got %v, want %v", output, input) + } + + // Test GetResourceType (accessed via cast) + if m, ok := migrator.(*V4ToV5Migrator); ok { + if resourceType := m.GetResourceType(); resourceType != "cloudflare_snippet_rules" { + t.Errorf("GetResourceType() = %v, want cloudflare_snippet_rules", resourceType) + } + + oldName, newName := m.GetResourceRename() + if oldName != "cloudflare_snippet_rules" || newName != "cloudflare_snippet_rules" { + t.Errorf("GetResourceRename() = (%v, %v), want (cloudflare_snippet_rules, cloudflare_snippet_rules)", oldName, newName) + } + } +}