ast: implement tests for extract-to-resource, move-attr-to-block, flatten-block, remove-resource-with-refs

ast-hclwrite-migration
Daniel Schmidt 2 months ago
parent 061e057607
commit 7fb89572f5
No known key found for this signature in database
GPG Key ID: 377C3A4D62FBBBE2

@ -42,6 +42,11 @@ func ParseFile(src []byte, filename string, schemas *providers.ProviderSchema) (
}, nil
}
// Filename returns the filename this file was parsed from.
func (f *File) Filename() string {
return f.filename
}
// Bytes returns the current content of the file, reflecting any
// mutations that have been applied.
func (f *File) Bytes() []byte {

@ -50,6 +50,11 @@ func (m *Module) Editable() bool {
return m.editable
}
// Files returns all files in this module.
func (m *Module) Files() []*File {
return m.files
}
// FindBlocks searches all files in this module for blocks matching
// blockType and firstLabel. If firstLabel is empty, matches blocks
// with no labels (e.g., terraform {}).

@ -3,50 +3,481 @@
package ast
import "testing"
import (
"testing"
// These tests document future AST operations needed for full migration coverage.
// Each is skipped with a description of the required capability.
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclwrite"
)
func TestFuture_ExtractToResource(t *testing.T) {
t.Skip("TODO: extract nested block/attribute from one resource into a new standalone resource block. " +
"Example: aws_s3_bucket.versioning {} → new aws_s3_bucket_versioning resource wired via bucket = aws_s3_bucket.X.id")
// TestMigrationPatterns_ExtractToResource exercises extraction of a nested block
// from one resource into a new standalone resource, wired back to the parent.
//
// Real-world examples:
// - AWS v3→v4: aws_s3_bucket.versioning {} → new aws_s3_bucket_versioning resource
// - AWS v3→v4: aws_s3_bucket.logging {} → new aws_s3_bucket_logging resource
func TestMigrationPatterns_ExtractToResource(t *testing.T) {
tests := []struct {
name string
files map[string]string
mutate func(t *testing.T, mod *Module)
want map[string]string
}{
{
name: "extract_nested_block_to_new_resource",
files: map[string]string{
"main.tf": `# S3 bucket with versioning enabled
resource "test_bucket" "main" {
name = "my-bucket" # the bucket name
// When implemented, this test should:
// 1. Parse an aws_s3_bucket with a versioning {} block
// 2. Remove the versioning block from aws_s3_bucket
// 3. Create a new aws_s3_bucket_versioning resource
// 4. Wire it to the bucket via bucket attribute
// 5. Verify the output has both resources with correct references
versioning {
enabled = true
}
}
func TestFuture_MoveAttributeToBlock(t *testing.T) {
t.Skip("TODO: move a top-level attribute into a nested block. " +
"Example: aws_instance.cpu_core_count → aws_instance.cpu_options { core_count }")
/* Output to prove surrounding code is preserved */
output "bucket_id" {
value = test_bucket.main.id
}
`,
},
mutate: func(t *testing.T, mod *Module) {
for _, r := range mod.FindBlocks("resource", "test_bucket") {
nested := r.Block.NestedBlocks("versioning")
if len(nested) == 0 {
continue
}
// Read attributes from nested block before removing it
attrs := nested[0].Attributes()
// Remove nested block from parent
r.Block.RemoveBlock("versioning")
// Create new standalone resource
labels := r.Block.Labels()
newBlock := r.File.AddBlock("resource", []string{"test_bucket_versioning", labels[1]})
// Wire to parent via traversal
newBlock.SetAttributeTraversal("bucket", hcl.Traversal{
hcl.TraverseRoot{Name: "test_bucket"},
hcl.TraverseAttr{Name: labels[1]},
hcl.TraverseAttr{Name: "id"},
})
// Copy attributes from the extracted nested block
for name, expr := range attrs {
newBlock.SetAttributeRaw(name, expr.BuildTokens(nil))
}
}
},
want: map[string]string{
"main.tf": `# S3 bucket with versioning enabled
resource "test_bucket" "main" {
name = "my-bucket" # the bucket name
}
/* Output to prove surrounding code is preserved */
output "bucket_id" {
value = test_bucket.main.id
}
resource "test_bucket_versioning" "main" {
bucket = test_bucket.main.id
enabled = true
}
`,
},
},
}
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))
}
}
})
}
}
// TestMigrationPatterns_MoveAttributeToBlock exercises moving a top-level attribute
// into a nested block.
//
// Real-world examples:
// - AWS v6: aws_instance.cpu_core_count → aws_instance.cpu_options { core_count }
// - AWS v6: aws_instance.cpu_threads_per_core → aws_instance.cpu_options { threads_per_core }
func TestMigrationPatterns_MoveAttributeToBlock(t *testing.T) {
tests := []struct {
name string
files map[string]string
mutate func(t *testing.T, mod *Module)
want map[string]string
}{
{
name: "move_attribute_into_nested_block",
files: map[string]string{
"main.tf": `# Instance with top-level CPU attributes
resource "test_instance" "web" {
ami = "abc-123" # the base image
instance_type = "c5.xlarge"
cpu_core_count = 2
cpu_threads_per_core = 1
}
`,
},
mutate: func(t *testing.T, mod *Module) {
for _, r := range mod.FindBlocks("resource", "test_instance") {
// Read expressions before removing
coreExpr := r.Block.GetAttributeExpression("cpu_core_count")
threadsExpr := r.Block.GetAttributeExpression("cpu_threads_per_core")
if coreExpr == nil && threadsExpr == nil {
continue
}
// Create nested block
cpuOpts := r.Block.AddBlock("cpu_options")
// Move attributes
if coreExpr != nil {
cpuOpts.SetAttributeRaw("core_count", coreExpr.BuildTokens(nil))
r.Block.RemoveAttribute("cpu_core_count")
}
if threadsExpr != nil {
cpuOpts.SetAttributeRaw("threads_per_core", threadsExpr.BuildTokens(nil))
r.Block.RemoveAttribute("cpu_threads_per_core")
}
}
},
want: map[string]string{
"main.tf": `# Instance with top-level CPU attributes
resource "test_instance" "web" {
ami = "abc-123" # the base image
instance_type = "c5.xlarge"
cpu_options {
core_count = 2
threads_per_core = 1
}
}
`,
},
},
{
name: "move_attribute_with_expression_value",
files: map[string]string{
"main.tf": `resource "test_instance" "dynamic" {
ami = var.ami_id
instance_type = var.instance_type
cpu_core_count = var.cpu_cores
cpu_threads_per_core = var.env == "prod" ? 2 : 1
}
`,
},
mutate: func(t *testing.T, mod *Module) {
for _, r := range mod.FindBlocks("resource", "test_instance") {
coreExpr := r.Block.GetAttributeExpression("cpu_core_count")
threadsExpr := r.Block.GetAttributeExpression("cpu_threads_per_core")
if coreExpr == nil && threadsExpr == nil {
continue
}
cpuOpts := r.Block.AddBlock("cpu_options")
if coreExpr != nil {
cpuOpts.SetAttributeRaw("core_count", coreExpr.BuildTokens(nil))
r.Block.RemoveAttribute("cpu_core_count")
}
if threadsExpr != nil {
cpuOpts.SetAttributeRaw("threads_per_core", threadsExpr.BuildTokens(nil))
r.Block.RemoveAttribute("cpu_threads_per_core")
}
}
},
want: map[string]string{
"main.tf": `resource "test_instance" "dynamic" {
ami = var.ami_id
instance_type = var.instance_type
cpu_options {
core_count = var.cpu_cores
threads_per_core = var.env == "prod" ? 2 : 1
}
}
`,
},
},
}
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))
}
}
})
}
}
// When implemented, this test should:
// 1. Parse an aws_instance with cpu_core_count = 2
// 2. Move it into cpu_options { core_count = 2 }
// 3. Verify the attribute is removed from top level and present in nested block
// TestMigrationPatterns_FlattenBlock exercises inlining nested block attributes
// into the parent block.
//
// Real-world examples:
// - AWS v5: aws_elasticache_replication_group.cluster_mode { num_node_groups, replicas_per_node_group }
// → top-level num_node_groups, replicas_per_node_group
func TestMigrationPatterns_FlattenBlock(t *testing.T) {
tests := []struct {
name string
files map[string]string
mutate func(t *testing.T, mod *Module)
want map[string]string
}{
{
name: "flatten_nested_block_to_parent",
files: map[string]string{
"main.tf": `# Replication group with cluster_mode block
resource "test_replication_group" "main" {
description = "my cluster" # the description
cluster_mode {
num_node_groups = 3
replicas_per_node_group = 2
}
}
`,
},
mutate: func(t *testing.T, mod *Module) {
for _, r := range mod.FindBlocks("resource", "test_replication_group") {
nested := r.Block.NestedBlocks("cluster_mode")
if len(nested) == 0 {
continue
}
// Read specific attributes by name for deterministic order
attrNames := []string{"num_node_groups", "replicas_per_node_group"}
exprs := make(map[string]*hclwrite.Expression)
for _, name := range attrNames {
if expr := nested[0].GetAttributeExpression(name); expr != nil {
exprs[name] = expr
}
}
// Remove the nested block
r.Block.RemoveBlock("cluster_mode")
func TestFuture_FlattenBlock(t *testing.T) {
t.Skip("TODO: inline nested block attributes into parent. " +
"Example: aws_elasticache_replication_group.cluster_mode { num_node_groups } → top-level num_node_groups")
// Add attributes to parent in deterministic order
for _, name := range attrNames {
if expr, ok := exprs[name]; ok {
r.Block.SetAttributeRaw(name, expr.BuildTokens(nil))
}
}
}
},
want: map[string]string{
"main.tf": `# Replication group with cluster_mode block
resource "test_replication_group" "main" {
description = "my cluster" # the description
// When implemented, this test should:
// 1. Parse a block with cluster_mode { num_node_groups = 3, replicas_per_node_group = 2 }
// 2. Flatten into top-level num_node_groups = 3, replicas_per_node_group = 2
// 3. Verify cluster_mode block is removed and attributes are at top level
num_node_groups = 3
replicas_per_node_group = 2
}
`,
},
},
}
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)
func TestFuture_RemoveResourceWithRefWarnings(t *testing.T) {
t.Skip("TODO: remove a resource block and add FIXME comments at all reference sites across files. " +
"Example: remove aws_opsworks_stack and add FIXME wherever aws_opsworks_stack.X is referenced")
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))
}
}
})
}
}
// TestMigrationPatterns_RemoveResourceWithRefWarnings exercises removing a resource
// block and adding FIXME comments to all files that reference it.
//
// Real-world examples:
// - AWS v6: aws_opsworks_* resources removed (17 resources)
// - AWS v5: aws_db_security_group, aws_redshift_security_group removed (EC2-Classic)
func TestMigrationPatterns_RemoveResourceWithRefWarnings(t *testing.T) {
tests := []struct {
name string
files map[string]string
mutate func(t *testing.T, mod *Module)
want map[string]string
}{
{
name: "remove_resource_and_warn_at_reference_sites",
files: map[string]string{
"main.tf": `# OpsWorks stack definition
resource "test_opsworks_stack" "main" {
name = "my-stack" # the stack name
region = "us-east-1"
}
// When implemented, this test should:
// 1. Parse multiple files with an aws_opsworks_stack and references to it
// 2. Remove the resource block
// 3. Add FIXME comments at each reference site in other files
// 4. Verify the resource is gone and all reference sites have comments
// A non-related resource in the same file
resource "test_instance" "web" {
ami = "abc-123"
instance_type = "t2.micro"
}
`,
"references.tf": `# Layer referencing the stack
resource "test_opsworks_layer" "app" {
stack_id = test_opsworks_stack.main.id
name = "app-layer"
}
output "stack_id" {
value = test_opsworks_stack.main.id
}
`,
"unrelated.tf": `# File with no references to the removed resource
resource "test_bucket" "logs" {
name = "logs-bucket"
}
`,
},
mutate: func(t *testing.T, mod *Module) {
results := mod.FindBlocks("resource", "test_opsworks_stack")
if len(results) == 0 {
return
}
// Collect which files reference this resource
prefix := hcl.Traversal{hcl.TraverseRoot{Name: "test_opsworks_stack"}}
// Track files that have references (deduplicate)
warned := make(map[string]bool)
for _, f := range mod.Files() {
if f.ReferencesPrefix(prefix) {
if !warned[f.Filename()] {
f.AppendComment("FIXME: test_opsworks_stack has been removed. Update references manually.")
warned[f.Filename()] = true
}
}
}
// Remove the resource blocks
for _, r := range results {
labels := r.Block.Labels()
r.File.RemoveBlock("resource", append([]string{"test_opsworks_stack"}, labels[1:]...))
}
},
want: map[string]string{
"main.tf": `
// A non-related resource in the same file
resource "test_instance" "web" {
ami = "abc-123"
instance_type = "t2.micro"
}
`,
"references.tf": `# Layer referencing the stack
resource "test_opsworks_layer" "app" {
stack_id = test_opsworks_stack.main.id
name = "app-layer"
}
output "stack_id" {
value = test_opsworks_stack.main.id
}
# FIXME: test_opsworks_stack has been removed. Update references manually.
`,
"unrelated.tf": `# File with no references to the removed resource
resource "test_bucket" "logs" {
name = "logs-bucket"
}
`,
},
},
}
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))
}
}
// Verify no extra files appeared
for name := range got {
if _, ok := tc.want[name]; !ok {
t.Errorf("unexpected output file %s", name)
}
}
})
}
}

Loading…
Cancel
Save