mirror of https://github.com/hashicorp/terraform
parent
4824e7c9c0
commit
4051eacfd3
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue