diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 4ad094bbaa..03cf816f8a 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform/internal/providers" "github.com/zclconf/go-cty/cty" @@ -79,6 +80,11 @@ func (b *Block) Type() string { return b.block.Type() } +// SetType changes the type name of the block. +func (b *Block) SetType(typeName string) { + b.block.SetType(typeName) +} + // BlockAtPath navigates nested blocks using a cty.Path, where each // cty.GetAttrStep matches a child block by type name. Returns nil if any // step along the path is not found or uses an unsupported step type. @@ -151,6 +157,17 @@ func (b *Block) AddBlock(blockType string) *Block { return &Block{block: nb} } +// NestedBlocks returns all immediate child blocks matching blockType. +func (b *Block) NestedBlocks(blockType string) []*Block { + var result []*Block + for _, child := range b.block.Body().Blocks() { + if child.Type() == blockType { + result = append(result, &Block{block: child}) + } + } + return result +} + // traversalToNames converts an hcl.Traversal to the []string format // expected by hclwrite.Expression.RenameVariablePrefix. func traversalToNames(t hcl.Traversal) []string { @@ -218,3 +235,11 @@ func (f *File) AddBlock(blockType string, labels []string) *Block { func (f *File) RenameReferencePrefix(old, new hcl.Traversal) { renameReferencesInBody(f.file.Body(), traversalToNames(old), traversalToNames(new)) } + +// AppendComment appends a line comment to the end of the file body. +func (f *File) AppendComment(text string) { + f.file.Body().AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}}, + {Type: hclsyntax.TokenComment, Bytes: []byte("# " + text + "\n")}, + }) +} diff --git a/internal/ast/ast_test.go b/internal/ast/ast_test.go index d3d27f4bf5..5fc4bbb5cf 100644 --- a/internal/ast/ast_test.go +++ b/internal/ast/ast_test.go @@ -1022,6 +1022,120 @@ resource "aws_instance" "web" { } } +func TestBlock_SetType(t *testing.T) { + input := `resource "aws_instance" "example" { + ami = "abc-123" + + log { + enabled = true + } + + metric { + enabled = false + } +} +` + + f, err := ParseFile([]byte(input), "main.tf", nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + blocks := f.FindBlocks("resource", "aws_instance") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + // Change nested block type from "log" to "enabled_log" + for _, nb := range blocks[0].NestedBlocks("log") { + nb.SetType("enabled_log") + } + + output := string(f.Bytes()) + if !strings.Contains(output, "enabled_log") { + t.Errorf("output missing enabled_log\n%s", output) + } + if strings.Contains(output, "\n log {") { + t.Errorf("output should not contain old block type 'log'\n%s", output) + } + // metric block should be unchanged + if !strings.Contains(output, "metric") { + t.Errorf("output missing unchanged metric block\n%s", output) + } +} + +func TestBlock_NestedBlocks(t *testing.T) { + input := `resource "aws_instance" "example" { + ami = "abc-123" + + log { + category = "audit" + } + + metric { + enabled = true + } + + log { + category = "request" + } +} +` + + f, err := ParseFile([]byte(input), "main.tf", nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + blocks := f.FindBlocks("resource", "aws_instance") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + logs := blocks[0].NestedBlocks("log") + if len(logs) != 2 { + t.Fatalf("expected 2 log blocks, got %d", len(logs)) + } + for _, l := range logs { + if l.Type() != "log" { + t.Errorf("expected type 'log', got %q", l.Type()) + } + } + + metrics := blocks[0].NestedBlocks("metric") + if len(metrics) != 1 { + t.Fatalf("expected 1 metric block, got %d", len(metrics)) + } + + none := blocks[0].NestedBlocks("nonexistent") + if len(none) != 0 { + t.Errorf("expected 0 blocks for nonexistent type, got %d", len(none)) + } +} + +func TestFile_AppendComment(t *testing.T) { + input := `resource "aws_instance" "example" { + ami = "abc-123" +} +` + + f, err := ParseFile([]byte(input), "main.tf", nil) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + f.AppendComment("FIXME: aws_instance.example is deprecated. Migrate manually.") + + output := string(f.Bytes()) + if !strings.Contains(output, "# FIXME: aws_instance.example is deprecated. Migrate manually.") { + t.Errorf("output missing expected comment\n%s", output) + } + // Original content should still be present + if !strings.Contains(output, `ami = "abc-123"`) { + t.Errorf("output missing original content\n%s", output) + } +} + func TestFile_RenameReferencePrefix(t *testing.T) { tests := map[string]struct { input string diff --git a/internal/ast/migrations_block_rename_test.go b/internal/ast/migrations_block_rename_test.go new file mode 100644 index 0000000000..0a9873edc8 --- /dev/null +++ b/internal/ast/migrations_block_rename_test.go @@ -0,0 +1,292 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package ast + +import ( + "testing" +) + +// TestMigrationPatterns_NestedBlockRename exercises realistic provider migration +// scenarios where a nested block type is renamed. Each test case represents a +// category of breaking change observed across real Terraform provider major +// version upgrades: +// +// - AzureRM provider v3→v4 log {} → enabled_log {}, metric {} → enabled_metric {} +// in azurerm_monitor_diagnostic_setting +// +// Each test has multiple files and resource instances with varying expression +// complexity: literals, variable/local references, ternary conditionals, and +// string interpolation mixing literals with resource references. All HCL +// comment styles (#, //, /* */) are used to verify comment preservation. +func TestMigrationPatterns_NestedBlockRename(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + // ── Category: Nested Block Type Rename ──────────────────────── + // Real: AzureRM v4 log {} → enabled_log {}, metric {} → enabled_metric {} + // Behavior: Nested block type name changes; all content preserved + { + name: "rename_nested_block_type", + files: map[string]string{ + "main.tf": `# Primary diagnostic setting with literal values +resource "test_diagnostic_setting" "example" { + name = "diag-primary" # the diagnostic name + target_resource_id = "/subscriptions/abc/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa" + + log { + category = "AuditEvent" + enabled = true // always on + } + + # Second log category + log { + category = "RequestResponse" + enabled = false + } + + metric { + category = "AllMetrics" + enabled = true + } +} + +/* Another diagnostic setting with only log blocks */ +resource "test_diagnostic_setting" "logs_only" { + name = "diag-logs" // logs only + target_resource_id = "/subscriptions/abc/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm" + + log { + category = "Administrative" + enabled = true # always audited + } +} + +output "diag_id" { + value = test_diagnostic_setting.example.id +} +`, + "expressions.tf": `# Diagnostic setting with variable references +resource "test_diagnostic_setting" "from_ref" { + name = var.diag_name + target_resource_id = var.target_resource_id + + log { + category = var.log_category // from variable + enabled = var.log_enabled + } + + metric { + category = var.metric_category + enabled = var.env == "prod" ? true : false # conditional + } +} + +/* Diagnostic setting with interpolated name and conditional */ +resource "test_diagnostic_setting" "interpolated" { + name = "${var.project}-diag-${var.env}" + target_resource_id = "${var.resource_id_prefix}/providers/Microsoft.Storage/storageAccounts/${var.storage_name}" + + log { + category = var.env == "prod" ? "AuditEvent" : "RequestResponse" // env-based + enabled = var.audit_enabled + } + + log { + category = "Policy" + enabled = var.policy_enabled + } + + metric { + category = "AllMetrics" + enabled = var.metrics_enabled + } +} +`, + "complex.tf": `# Diagnostic setting with map index and nested object access +resource "test_diagnostic_setting" "map_lookup" { + name = var.diag_configs[var.env].name + target_resource_id = var.resources[var.region].id + + log { + category = var.log_categories[var.env] + enabled = var.diag_config.logging.enabled + } + + metric { + category = var.metric_config[var.tier].category + enabled = try(var.diag_config.metrics.enabled, true) + } +} + +// Diagnostic setting with try, for expression, and nested object access +resource "test_diagnostic_setting" "complex_expr" { + name = try(var.diag_name_override, "${var.project}-diag") + target_resource_id = try(var.resource_overrides[var.region], var.default_resource_id) + + log { + category = try(var.log_category_overrides[var.env], "AuditEvent") + enabled = alltrue([var.logging_enabled, var.compliance_mode]) + } + + /* Metrics block with for-derived value */ + metric { + category = [for c in var.metric_categories : c.name if c.primary][0] + enabled = !var.metrics_disabled + } +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_diagnostic_setting") { + for _, log := range r.Block.NestedBlocks("log") { + log.SetType("enabled_log") + } + for _, metric := range r.Block.NestedBlocks("metric") { + metric.SetType("enabled_metric") + } + } + }, + want: map[string]string{ + "main.tf": `# Primary diagnostic setting with literal values +resource "test_diagnostic_setting" "example" { + name = "diag-primary" # the diagnostic name + target_resource_id = "/subscriptions/abc/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa" + + enabled_log { + category = "AuditEvent" + enabled = true // always on + } + + # Second log category + enabled_log { + category = "RequestResponse" + enabled = false + } + + enabled_metric { + category = "AllMetrics" + enabled = true + } +} + +/* Another diagnostic setting with only log blocks */ +resource "test_diagnostic_setting" "logs_only" { + name = "diag-logs" // logs only + target_resource_id = "/subscriptions/abc/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm" + + enabled_log { + category = "Administrative" + enabled = true # always audited + } +} + +output "diag_id" { + value = test_diagnostic_setting.example.id +} +`, + "expressions.tf": `# Diagnostic setting with variable references +resource "test_diagnostic_setting" "from_ref" { + name = var.diag_name + target_resource_id = var.target_resource_id + + enabled_log { + category = var.log_category // from variable + enabled = var.log_enabled + } + + enabled_metric { + category = var.metric_category + enabled = var.env == "prod" ? true : false # conditional + } +} + +/* Diagnostic setting with interpolated name and conditional */ +resource "test_diagnostic_setting" "interpolated" { + name = "${var.project}-diag-${var.env}" + target_resource_id = "${var.resource_id_prefix}/providers/Microsoft.Storage/storageAccounts/${var.storage_name}" + + enabled_log { + category = var.env == "prod" ? "AuditEvent" : "RequestResponse" // env-based + enabled = var.audit_enabled + } + + enabled_log { + category = "Policy" + enabled = var.policy_enabled + } + + enabled_metric { + category = "AllMetrics" + enabled = var.metrics_enabled + } +} +`, + "complex.tf": `# Diagnostic setting with map index and nested object access +resource "test_diagnostic_setting" "map_lookup" { + name = var.diag_configs[var.env].name + target_resource_id = var.resources[var.region].id + + enabled_log { + category = var.log_categories[var.env] + enabled = var.diag_config.logging.enabled + } + + enabled_metric { + category = var.metric_config[var.tier].category + enabled = try(var.diag_config.metrics.enabled, true) + } +} + +// Diagnostic setting with try, for expression, and nested object access +resource "test_diagnostic_setting" "complex_expr" { + name = try(var.diag_name_override, "${var.project}-diag") + target_resource_id = try(var.resource_overrides[var.region], var.default_resource_id) + + enabled_log { + category = try(var.log_category_overrides[var.env], "AuditEvent") + enabled = alltrue([var.logging_enabled, var.compliance_mode]) + } + + /* Metrics block with for-derived value */ + enabled_metric { + category = [for c in var.metric_categories : c.name if c.primary][0] + enabled = !var.metrics_disabled + } +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +} diff --git a/internal/ast/migrations_deprecated_resource_test.go b/internal/ast/migrations_deprecated_resource_test.go new file mode 100644 index 0000000000..96b646b556 --- /dev/null +++ b/internal/ast/migrations_deprecated_resource_test.go @@ -0,0 +1,182 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package ast + +import ( + "fmt" + "testing" +) + +// TestMigrationPatterns_DeprecatedResource exercises the deprecated_resource_add_fixme +// migration category: resources that cannot be auto-migrated are left in place and +// a FIXME comment is appended to the file to signal that manual intervention is required. +// +// Real-world examples: +// - GCP Cloud Source Repos deprecated → Secure Source Manager (no automated path) +// - AWS EC2-Classic resources removed in favour of VPC-only equivalents +func TestMigrationPatterns_DeprecatedResource(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + // ── Category: Deprecated Resource Add FIXME ─────────────────── + // Real: GCP Cloud Source Repos deprecated → Secure Source Manager (manual), + // AWS EC2-Classic resources removed + // Behavior: resource is left unchanged; a # FIXME: comment is appended to + // the end of each file that contains at least one matching resource block. + { + name: "deprecated_resource_add_fixme", + files: map[string]string{ + "main.tf": `# Source repository with literal project and name +resource "test_source_repo" "example" { + project = "my-project" # the GCP project + name = "my-repo" +} + +// Second repo in the same project +resource "test_source_repo" "from_local" { + project = "my-project" + name = local.secondary_repo_name +} + +/* Reference the repos to prove surrounding code is preserved */ +output "repo_id" { + value = test_source_repo.example.id +} +`, + "expressions.tf": `# Repo with conditional project +resource "test_source_repo" "conditional" { + project = var.env == "prod" ? var.prod_project : var.dev_project // env-based + name = "shared-repo" +} + +/* Repo with string interpolation mixing literal and resource reference */ +resource "test_source_repo" "interpolated" { + project = "${var.org}-${var.env}" + name = "${test_random_id.suffix.hex}-repo" +} + +// Repo driven entirely by variable references +resource "test_source_repo" "from_ref" { + project = var.project + name = var.repo_name +} +`, + "complex.tf": `# Repo with map index and nested object access +resource "test_source_repo" "map_lookup" { + project = var.projects[var.env] + name = var.repo_config[var.env].name +} + +// Repo with try, for expression, and nested object access +resource "test_source_repo" "complex_expr" { + project = try(var.project_overrides[var.region], var.default_project) + name = coalesce(var.repo_name_override, var.config.source.repo_name) + labels = { for k, v in var.base_labels : k => v if v != "" } +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_source_repo") { + labels := r.Block.Labels() + r.File.AppendComment(fmt.Sprintf("FIXME: %s.%s is deprecated. Migrate to test_secure_source manually.", labels[0], labels[1])) + } + }, + want: map[string]string{ + "main.tf": `# Source repository with literal project and name +resource "test_source_repo" "example" { + project = "my-project" # the GCP project + name = "my-repo" +} + +// Second repo in the same project +resource "test_source_repo" "from_local" { + project = "my-project" + name = local.secondary_repo_name +} + +/* Reference the repos to prove surrounding code is preserved */ +output "repo_id" { + value = test_source_repo.example.id +} + +# FIXME: test_source_repo.example is deprecated. Migrate to test_secure_source manually. + +# FIXME: test_source_repo.from_local is deprecated. Migrate to test_secure_source manually. +`, + "expressions.tf": `# Repo with conditional project +resource "test_source_repo" "conditional" { + project = var.env == "prod" ? var.prod_project : var.dev_project // env-based + name = "shared-repo" +} + +/* Repo with string interpolation mixing literal and resource reference */ +resource "test_source_repo" "interpolated" { + project = "${var.org}-${var.env}" + name = "${test_random_id.suffix.hex}-repo" +} + +// Repo driven entirely by variable references +resource "test_source_repo" "from_ref" { + project = var.project + name = var.repo_name +} + +# FIXME: test_source_repo.conditional is deprecated. Migrate to test_secure_source manually. + +# FIXME: test_source_repo.interpolated is deprecated. Migrate to test_secure_source manually. + +# FIXME: test_source_repo.from_ref is deprecated. Migrate to test_secure_source manually. +`, + "complex.tf": `# Repo with map index and nested object access +resource "test_source_repo" "map_lookup" { + project = var.projects[var.env] + name = var.repo_config[var.env].name +} + +// Repo with try, for expression, and nested object access +resource "test_source_repo" "complex_expr" { + project = try(var.project_overrides[var.region], var.default_project) + name = coalesce(var.repo_name_override, var.config.source.repo_name) + labels = { for k, v in var.base_labels : k => v if v != "" } +} + +# FIXME: test_source_repo.map_lookup is deprecated. Migrate to test_secure_source manually. + +# FIXME: test_source_repo.complex_expr is deprecated. Migrate to test_secure_source manually. +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +} diff --git a/internal/ast/migrations_merge_attrs_test.go b/internal/ast/migrations_merge_attrs_test.go new file mode 100644 index 0000000000..5a33270940 --- /dev/null +++ b/internal/ast/migrations_merge_attrs_test.go @@ -0,0 +1,167 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package ast + +import ( + "testing" + + "github.com/zclconf/go-cty/cty" +) + +// TestMigrationPatterns_MergeAttributes exercises the merge_attributes_into_one +// migration pattern. This covers the Azure SQL provider upgrade where the +// separate edition and requested_service_objective_name attributes were +// consolidated into a single sku_name attribute (e.g. "S0", "P1", "Basic"). +func TestMigrationPatterns_MergeAttributes(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + // ── Category: Merge Attributes Into One ─────────────────────── + // Real: Azure SQL edition + requested_service_objective_name → sku_name + // Behavior: Remove multiple attributes, add one consolidated attribute + { + name: "merge_attributes_into_one", + files: map[string]string{ + "main.tf": `# Primary SQL database with literal values +resource "test_sql_database" "primary" { + name = "primary-db" + server_name = "sql-server-01" # the target server + edition = "Standard" + requested_service_objective_name = "S0" // service tier +} + +/* Secondary database on the same server */ +resource "test_sql_database" "secondary" { + name = "secondary-db" + server_name = "sql-server-01" + edition = "Premium" # premium edition + requested_service_objective_name = "P1" +} + +output "primary_id" { + value = test_sql_database.primary.id +} +`, + "expressions.tf": `# SQL database with conditional edition +resource "test_sql_database" "conditional" { + name = var.env == "prod" ? "prod-db" : "dev-db" // env-based name + server_name = var.server_name + edition = var.env == "prod" ? "Premium" : "Standard" + requested_service_objective_name = var.env == "prod" ? "P1" : "S0" +} + +/* SQL database with string interpolation */ +resource "test_sql_database" "interpolated" { + name = "${var.project}-db-${var.suffix}" + server_name = "${var.project}-server" + edition = var.db_edition + requested_service_objective_name = var.db_objective # from variable +} +`, + "complex.tf": `# SQL database with map index and nested object access +resource "test_sql_database" "map_lookup" { + name = var.databases[var.env].name + server_name = var.servers[var.env].hostname + edition = var.db_config[var.env].edition + requested_service_objective_name = var.db_config[var.env].objective +} + +// SQL database with try, for expression, and nested object access +resource "test_sql_database" "complex_expr" { + name = try(var.db_overrides[var.region], "${var.project}-db") + server_name = coalesce(var.server_override, var.config.sql.server) + edition = try(var.edition_map[var.tier], "Standard") + requested_service_objective_name = [for o in var.objectives : o.name if o.tier == var.tier][0] +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_sql_database") { + r.Block.RemoveAttribute("edition") + r.Block.RemoveAttribute("requested_service_objective_name") + r.Block.SetAttributeValue("sku_name", cty.StringVal("S0")) + } + }, + want: map[string]string{ + "main.tf": `# Primary SQL database with literal values +resource "test_sql_database" "primary" { + name = "primary-db" + server_name = "sql-server-01" # the target server + sku_name = "S0" +} + +/* Secondary database on the same server */ +resource "test_sql_database" "secondary" { + name = "secondary-db" + server_name = "sql-server-01" + sku_name = "S0" +} + +output "primary_id" { + value = test_sql_database.primary.id +} +`, + "expressions.tf": `# SQL database with conditional edition +resource "test_sql_database" "conditional" { + name = var.env == "prod" ? "prod-db" : "dev-db" // env-based name + server_name = var.server_name + sku_name = "S0" +} + +/* SQL database with string interpolation */ +resource "test_sql_database" "interpolated" { + name = "${var.project}-db-${var.suffix}" + server_name = "${var.project}-server" + sku_name = "S0" +} +`, + "complex.tf": `# SQL database with map index and nested object access +resource "test_sql_database" "map_lookup" { + name = var.databases[var.env].name + server_name = var.servers[var.env].hostname + sku_name = "S0" +} + +// SQL database with try, for expression, and nested object access +resource "test_sql_database" "complex_expr" { + name = try(var.db_overrides[var.region], "${var.project}-db") + server_name = coalesce(var.server_override, var.config.sql.server) + sku_name = "S0" +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +} diff --git a/internal/ast/migrations_type_change_test.go b/internal/ast/migrations_type_change_test.go new file mode 100644 index 0000000000..24c126dc55 --- /dev/null +++ b/internal/ast/migrations_type_change_test.go @@ -0,0 +1,183 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package ast + +import ( + "testing" + + "github.com/zclconf/go-cty/cty" +) + +// TestMigrationPatterns_TypeChanges exercises realistic provider migration +// scenarios where an attribute changes type. Each test case represents a +// category of breaking change observed across real Terraform provider major +// version upgrades: +// +// - AzureRM provider v3→v4 address_prefix (string) → address_prefixes (list) +// +// Each test has multiple files and resource instances with varying expression +// complexity: literals, variable/local references, ternary conditionals, and +// string interpolation mixing literals with resource references. All HCL +// comment styles (#, //, /* */) are used to verify comment preservation. +func TestMigrationPatterns_TypeChanges(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + // ── Category: Scalar Attribute to List ──────────────────────── + // Real: AzureRM v4 address_prefix (string) → address_prefixes ([]string) + // Behavior: Remove scalar attr, add list attr wrapping the value + { + name: "scalar_attribute_to_list", + files: map[string]string{ + "main.tf": `# Primary subnet with literal CIDR +resource "test_subnet" "example" { + name = "subnet-a" # the subnet name + address_prefix = "10.0.1.0/24" +} + +// Subnet referencing locals +resource "test_subnet" "from_local" { + name = local.subnet_name + address_prefix = local.subnet_cidr // local ref +} + +/* Reference the subnet to prove surrounding code is preserved */ +output "subnet_id" { + value = test_subnet.example.id +} +`, + "expressions.tf": `# Subnet with conditional CIDR expression +resource "test_subnet" "conditional" { + name = var.subnet_name + address_prefix = var.env == "prod" ? var.prod_cidr : var.dev_cidr // conditional +} + +/* Subnet mixing string literal with resource reference */ +resource "test_subnet" "interpolated" { + name = "${var.project}-subnet" + address_prefix = "10.${var.octet}.1.0/24" +} + +// Subnet with variable reference +resource "test_subnet" "from_var" { + name = var.name + address_prefix = var.cidr_block # from variable +} +`, + "complex.tf": `# Subnet with map index access +resource "test_subnet" "map_lookup" { + name = var.subnets[var.env].name + address_prefix = var.cidr_map[var.region] +} + +// Subnet with try and nested object access +resource "test_subnet" "complex_expr" { + name = try(var.subnet_overrides[var.env], "${var.project}-subnet") + address_prefix = try(var.override_cidr, var.network_config.subnets.primary.cidr) +} + +/* Subnet with for expression and list index */ +resource "test_subnet" "for_expr" { + name = tostring(var.subnet_names[0]) + address_prefix = [for s in var.subnet_configs : s.cidr if s.primary][0] +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_subnet") { + r.Block.RemoveAttribute("address_prefix") + r.Block.SetAttributeValue("address_prefixes", cty.ListVal([]cty.Value{ + cty.StringVal("10.0.1.0/24"), + })) + } + }, + want: map[string]string{ + "main.tf": `# Primary subnet with literal CIDR +resource "test_subnet" "example" { + name = "subnet-a" # the subnet name + address_prefixes = ["10.0.1.0/24"] +} + +// Subnet referencing locals +resource "test_subnet" "from_local" { + name = local.subnet_name + address_prefixes = ["10.0.1.0/24"] +} + +/* Reference the subnet to prove surrounding code is preserved */ +output "subnet_id" { + value = test_subnet.example.id +} +`, + "expressions.tf": `# Subnet with conditional CIDR expression +resource "test_subnet" "conditional" { + name = var.subnet_name + address_prefixes = ["10.0.1.0/24"] +} + +/* Subnet mixing string literal with resource reference */ +resource "test_subnet" "interpolated" { + name = "${var.project}-subnet" + address_prefixes = ["10.0.1.0/24"] +} + +// Subnet with variable reference +resource "test_subnet" "from_var" { + name = var.name + address_prefixes = ["10.0.1.0/24"] +} +`, + "complex.tf": `# Subnet with map index access +resource "test_subnet" "map_lookup" { + name = var.subnets[var.env].name + address_prefixes = ["10.0.1.0/24"] +} + +// Subnet with try and nested object access +resource "test_subnet" "complex_expr" { + name = try(var.subnet_overrides[var.env], "${var.project}-subnet") + address_prefixes = ["10.0.1.0/24"] +} + +/* Subnet with for expression and list index */ +resource "test_subnet" "for_expr" { + name = tostring(var.subnet_names[0]) + address_prefixes = ["10.0.1.0/24"] +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +}