diff --git a/internal/command/migrate/engine.go b/internal/command/migrate/engine.go new file mode 100644 index 0000000000..f72c208ce5 --- /dev/null +++ b/internal/command/migrate/engine.go @@ -0,0 +1,113 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package migrate + +import ( + "bytes" + "os" + "path/filepath" +) + +// FileResult holds the before/after content for a single file. +type FileResult struct { + Filename string + Before []byte + After []byte +} + +// SubMigrationResult holds the outcome of applying one sub-migration. +type SubMigrationResult struct { + SubMigration SubMigration + Files []FileResult // only files that actually changed +} + +// Apply runs all sub-migrations against .tf files in dir. +// It does NOT write files — returns results for the caller to inspect/write. +// Sub-migrations chain: each sees the output of the previous one. +func Apply(dir string, m Migration) ([]SubMigrationResult, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + // Collect .tf filenames + var tfFiles []string + for _, e := range entries { + if e.IsDir() { + continue + } + if filepath.Ext(e.Name()) == ".tf" { + tfFiles = append(tfFiles, e.Name()) + } + } + + if len(tfFiles) == 0 { + return nil, nil + } + + // Read initial file contents + current := make(map[string][]byte, len(tfFiles)) + for _, name := range tfFiles { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, err + } + current[name] = data + } + + var results []SubMigrationResult + + for _, sub := range m.SubMigrations { + var files []FileResult + + for _, name := range tfFiles { + before := current[name] + after, err := sub.Apply(name, before) + if err != nil { + return nil, err + } + + if !bytes.Equal(before, after) { + files = append(files, FileResult{ + Filename: name, + Before: before, + After: after, + }) + } + + // Chain: update current state for next sub-migration + current[name] = after + } + + if len(files) > 0 { + results = append(results, SubMigrationResult{ + SubMigration: sub, + Files: files, + }) + } + } + + return results, nil +} + +// WriteResults writes all changed files to disk using the final state +// from the results. For each file that appears in multiple sub-migration +// results, only the last (final) state is written. +func WriteResults(dir string, results []SubMigrationResult) error { + // Collect the final state of each file across all sub-migration results. + final := make(map[string][]byte) + for _, r := range results { + for _, f := range r.Files { + final[f.Filename] = f.After + } + } + + for name, data := range final { + if err := os.WriteFile(filepath.Join(dir, name), data, 0644); err != nil { + return err + } + } + + return nil +} diff --git a/internal/command/migrate/engine_test.go b/internal/command/migrate/engine_test.go new file mode 100644 index 0000000000..31c5f654a4 --- /dev/null +++ b/internal/command/migrate/engine_test.go @@ -0,0 +1,210 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package migrate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestEngineApply(t *testing.T) { + dir := t.TempDir() + + original := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" + acl = "private" + + tags = { + Name = "my-bucket" + } +} +` + if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte(original), 0644); err != nil { + t.Fatal(err) + } + + migrations := awsMigrations() + results, err := Apply(dir, migrations[0]) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if len(results) == 0 { + t.Fatal("expected non-empty results") + } + + // Verify the original file on disk is unchanged (Apply doesn't write) + onDisk, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(original, string(onDisk)); diff != "" { + t.Fatalf("Apply should not modify files on disk (-want +got):\n%s", diff) + } + + // Verify the first result is the acl sub-migration + if results[0].SubMigration.Name != "s3-bucket-acl" { + t.Fatalf("expected first sub-migration to be s3-bucket-acl, got %s", results[0].SubMigration.Name) + } + + // Verify the result contains transformed content + if len(results[0].Files) == 0 { + t.Fatal("expected files in first result") + } + after := string(results[0].Files[0].After) + if !strings.Contains(after, `aws_s3_bucket_acl`) { + t.Fatalf("expected transformed content to contain aws_s3_bucket_acl, got:\n%s", after) + } +} + +func TestEngineApplyNoMatch(t *testing.T) { + dir := t.TempDir() + + content := `resource "aws_instance" "example" { + ami = "ami-123456" + instance_type = "t2.micro" +} +` + if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + migrations := awsMigrations() + results, err := Apply(dir, migrations[0]) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if len(results) != 0 { + t.Fatalf("expected empty results for non-matching file, got %d results", len(results)) + } +} + +func TestEngineIgnoresNonTfFiles(t *testing.T) { + dir := t.TempDir() + + content := `# This is a markdown file with acl = "private" in it +resource "aws_s3_bucket" "example" { + acl = "private" +} +` + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + migrations := awsMigrations() + results, err := Apply(dir, migrations[0]) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if len(results) != 0 { + t.Fatalf("expected empty results for non-.tf files, got %d results", len(results)) + } +} + +func TestEngineWriteResults(t *testing.T) { + dir := t.TempDir() + + original := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" + acl = "private" +} +` + if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte(original), 0644); err != nil { + t.Fatal(err) + } + + migrations := awsMigrations() + results, err := Apply(dir, migrations[0]) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if err := WriteResults(dir, results); err != nil { + t.Fatalf("unexpected error writing results: %s", err) + } + + // Verify the file on disk has been updated + onDisk, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + + if string(onDisk) == original { + t.Fatal("expected file to be updated after WriteResults") + } + if !strings.Contains(string(onDisk), `aws_s3_bucket_acl`) { + t.Fatalf("expected written file to contain aws_s3_bucket_acl, got:\n%s", string(onDisk)) + } +} + +func TestEngineChaining(t *testing.T) { + dir := t.TempDir() + + original := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" + acl = "private" + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST"] + allowed_origins = ["https://example.com"] + } +} +` + if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte(original), 0644); err != nil { + t.Fatal(err) + } + + migrations := awsMigrations() + results, err := Apply(dir, migrations[0]) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // We expect at least two sub-migration results: acl and cors + if len(results) < 2 { + t.Fatalf("expected at least 2 sub-migration results, got %d", len(results)) + } + + // Verify sub-migration names + names := make([]string, len(results)) + for i, r := range results { + names[i] = r.SubMigration.Name + } + + if names[0] != "s3-bucket-acl" { + t.Fatalf("expected first result to be s3-bucket-acl, got %s", names[0]) + } + if names[1] != "s3-bucket-cors" { + t.Fatalf("expected second result to be s3-bucket-cors, got %s", names[1]) + } + + // The final sub-migration result should have the output after both transforms. + // Get the last file result for main.tf from the cors sub-migration. + finalAfter := string(results[1].Files[0].After) + if !strings.Contains(finalAfter, `aws_s3_bucket_acl`) { + t.Fatalf("expected chained output to contain aws_s3_bucket_acl, got:\n%s", finalAfter) + } + if !strings.Contains(finalAfter, `aws_s3_bucket_cors_configuration`) { + t.Fatalf("expected chained output to contain aws_s3_bucket_cors_configuration, got:\n%s", finalAfter) + } + + // Verify the original bucket resource no longer contains cors_rule. + // Find the aws_s3_bucket block and check it does not have cors_rule. + bucketIdx := strings.Index(finalAfter, `resource "aws_s3_bucket" "example"`) + corsIdx := strings.Index(finalAfter, `resource "aws_s3_bucket_cors_configuration"`) + if bucketIdx < 0 || corsIdx < 0 { + t.Fatal("expected both aws_s3_bucket and aws_s3_bucket_cors_configuration in output") + } + bucketBlock := finalAfter[bucketIdx:corsIdx] + if strings.Contains(bucketBlock, "cors_rule") { + t.Fatalf("expected cors_rule to be extracted from aws_s3_bucket block, got:\n%s", bucketBlock) + } +}