Add migration engine with Apply and WriteResults functions

Implement the migration engine that applies migrations to .tf files in a
directory. Apply scans for .tf files, chains sub-migrations so each sees
the output of the previous one, and returns results without writing to
disk. WriteResults writes the final state of changed files.
prototype-migrate-ux
Daniel Schmidt 3 months ago
parent c8c313ee7f
commit 8a5a9de378
No known key found for this signature in database
GPG Key ID: 377C3A4D62FBBBE2

@ -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
}

@ -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)
}
}
Loading…
Cancel
Save