From 062034180dc810f8a9e71dba3d3876dedce81d59 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 6 Mar 2026 12:18:25 +0100 Subject: [PATCH] Add terraform migrate command, list subcommand, and apply subcommand Implements the three command files for the migrate feature: - migrate_command.go: Parent command that shows help or delegates to apply when first arg contains "/" (migration ID shorthand) - migrate_list.go: Lists available migrations for the current working directory by scanning .tf files against all registered migrations - migrate_apply.go: Applies a specific migration with three modes: default (apply all), dry-run (show diffs without writing), and step (interactive) Includes tests for list (with/without matches) and apply (default, dry-run, not-found, no-changes). --- internal/command/migrate_apply.go | 200 +++++++++++++++++++++++++ internal/command/migrate_apply_test.go | 131 ++++++++++++++++ internal/command/migrate_command.go | 49 ++++++ internal/command/migrate_list.go | 80 ++++++++++ internal/command/migrate_list_test.go | 63 ++++++++ 5 files changed, 523 insertions(+) create mode 100644 internal/command/migrate_apply.go create mode 100644 internal/command/migrate_apply_test.go create mode 100644 internal/command/migrate_command.go create mode 100644 internal/command/migrate_list.go create mode 100644 internal/command/migrate_list_test.go diff --git a/internal/command/migrate_apply.go b/internal/command/migrate_apply.go new file mode 100644 index 0000000000..11f6c1b5cd --- /dev/null +++ b/internal/command/migrate_apply.go @@ -0,0 +1,200 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/migrate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// MigrateApplyCommand is a Command implementation that applies a specific +// migration to the Terraform configuration in the current working directory. +type MigrateApplyCommand struct { + Meta +} + +func (c *MigrateApplyCommand) Run(rawArgs []string) int { + rawArgs = c.Meta.process(rawArgs) + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) + + args, diags := arguments.ParseMigrateApply(rawArgs) + if diags.HasErrors() { + c.View.Diagnostics(diags) + return 1 + } + + view := views.NewMigrateApply(args.ViewType, c.View) + dir := "." + + // Find migration + registry := migrate.NewRegistry() + m, err := registry.Find(args.MigrationID) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Migration not found", + err.Error(), + )) + view.Diagnostics(diags) + return 1 + } + + // Run engine + results, err := migrate.Apply(dir, m) + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + if len(results) == 0 { + view.Summary(0, 0) + return 0 + } + + switch { + case args.DryRun: + return c.dryRun(view, args.MigrationID, results) + case args.Step: + return c.step(view, dir, m, results) + default: + return c.apply(view, dir, args.MigrationID, results) + } +} + +func (c *MigrateApplyCommand) apply(view views.MigrateApply, dir, id string, results []migrate.SubMigrationResult) int { + view.Applying(id) + + // Write all results + if err := migrate.WriteResults(dir, results); err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to write migration results", + err.Error(), + )) + view.Diagnostics(diags) + return 1 + } + + // Show progress for each sub-migration + totalChanges := 0 + allFiles := map[string]bool{} + for _, r := range results { + var filenames []string + for _, f := range r.Files { + filenames = append(filenames, f.Filename) + allFiles[f.Filename] = true + } + totalChanges++ + view.Progress(r.SubMigration, filenames) + } + + view.Summary(totalChanges, len(allFiles)) + return 0 +} + +func (c *MigrateApplyCommand) dryRun(view views.MigrateApply, id string, results []migrate.SubMigrationResult) int { + view.DryRunHeader(id) + + totalChanges := 0 + allFiles := map[string]bool{} + + // Build first-seen (before) and last-seen (after) per filename + firstBefore := map[string][]byte{} + lastAfter := map[string][]byte{} + + for _, r := range results { + totalChanges++ + for _, f := range r.Files { + if _, seen := firstBefore[f.Filename]; !seen { + firstBefore[f.Filename] = f.Before + } + lastAfter[f.Filename] = f.After + allFiles[f.Filename] = true + } + } + + for filename := range allFiles { + view.Diff(filename, firstBefore[filename], lastAfter[filename]) + } + + view.DryRunSummary(totalChanges, len(allFiles)) + return 0 +} + +func (c *MigrateApplyCommand) step(view views.MigrateApply, dir string, m migrate.Migration, results []migrate.SubMigrationResult) int { + totalChanges := 0 + allFiles := map[string]bool{} + + for i, r := range results { + view.StepHeader(i+1, len(results), r.SubMigration) + + // Show diff for each file in this sub-migration + for _, f := range r.Files { + view.Diff(f.Filename, f.Before, f.After) + } + + choice := view.StepPrompt(c.Streams) + switch choice { + case 'y': + // Write just this sub-migration's files + for _, f := range r.Files { + if err := os.WriteFile(filepath.Join(dir, f.Filename), f.After, 0644); err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to write file", + err.Error(), + )) + view.Diagnostics(diags) + return 1 + } + allFiles[f.Filename] = true + } + totalChanges++ + case 'n': + // Skip this sub-migration + continue + case 'q': + // Quit early + view.Summary(totalChanges, len(allFiles)) + return 0 + } + } + + view.Summary(totalChanges, len(allFiles)) + return 0 +} + +func (c *MigrateApplyCommand) Help() string { + helpText := ` +Usage: terraform [global options] migrate [options] + + Applies the specified migration to the Terraform configuration in the + current working directory. The migration ID is in the format + namespace/provider/name (e.g. hashicorp/aws/v3-to-v4). + +Options: + + -dry-run Show what changes would be made without modifying any files. + + -step Apply the migration one sub-migration at a time, prompting + before each step. + + -json Output in a machine-readable JSON format. +` + return strings.TrimSpace(helpText) +} + +func (c *MigrateApplyCommand) Synopsis() string { + return "Apply a specific migration" +} diff --git a/internal/command/migrate_apply_test.go b/internal/command/migrate_apply_test.go new file mode 100644 index 0000000000..2ccbab3066 --- /dev/null +++ b/internal/command/migrate_apply_test.go @@ -0,0 +1,131 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMigrateApply_default(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "main.tf"), []byte(`resource "aws_s3_bucket" "test" { + bucket = "my-bucket" + acl = "private" +} +`), 0644) + t.Chdir(dir) + + view, done := testView(t) + c := &MigrateApplyCommand{ + Meta: Meta{View: view}, + } + + code := c.Run([]string{"hashicorp/aws/v3-to-v4"}) + if code != 0 { + t.Fatalf("exit code %d", code) + } + + output := done(t) + got := output.Stdout() + if !strings.Contains(got, "Applying") { + t.Errorf("expected Applying header:\n%s", got) + } + + // Verify file was modified + content, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "aws_s3_bucket_acl") { + t.Error("expected file to contain aws_s3_bucket_acl") + } +} + +func TestMigrateApply_dryRun(t *testing.T) { + dir := t.TempDir() + input := `resource "aws_s3_bucket" "test" { + bucket = "my-bucket" + acl = "private" +} +` + os.WriteFile(filepath.Join(dir, "main.tf"), []byte(input), 0644) + t.Chdir(dir) + + view, done := testView(t) + c := &MigrateApplyCommand{ + Meta: Meta{View: view}, + } + + code := c.Run([]string{"-dry-run", "hashicorp/aws/v3-to-v4"}) + if code != 0 { + t.Fatalf("exit code %d", code) + } + + output := done(t) + got := output.Stdout() + if !strings.Contains(got, "Planning") { + t.Errorf("expected Planning header:\n%s", got) + } + if !strings.Contains(got, "would be applied") { + t.Errorf("expected dry-run summary:\n%s", got) + } + + // Verify file was NOT modified + content, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + if string(content) != input { + t.Error("dry-run should not modify files") + } +} + +func TestMigrateApply_notFound(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + view, done := testView(t) + c := &MigrateApplyCommand{ + Meta: Meta{View: view}, + } + + code := c.Run([]string{"nonexistent/provider/migration"}) + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } + + output := done(t) + got := output.Stderr() + if !strings.Contains(got, "Migration not found") { + t.Errorf("expected 'Migration not found' in error output:\n%s", got) + } +} + +func TestMigrateApply_noChanges(t *testing.T) { + dir := t.TempDir() + // A file with no applicable changes for the AWS migration + os.WriteFile(filepath.Join(dir, "main.tf"), []byte(`resource "null_resource" "test" { +} +`), 0644) + t.Chdir(dir) + + view, done := testView(t) + c := &MigrateApplyCommand{ + Meta: Meta{View: view}, + } + + code := c.Run([]string{"hashicorp/aws/v3-to-v4"}) + if code != 0 { + t.Fatalf("exit code %d", code) + } + + output := done(t) + got := output.Stdout() + if !strings.Contains(got, "Applied 0 changes") { + t.Errorf("expected zero changes summary:\n%s", got) + } +} diff --git a/internal/command/migrate_command.go b/internal/command/migrate_command.go new file mode 100644 index 0000000000..6eac15a19b --- /dev/null +++ b/internal/command/migrate_command.go @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/hashicorp/cli" +) + +// MigrateCommand is a Command implementation that either shows help for +// the migrate subcommands or delegates to MigrateApplyCommand when the +// first argument looks like a migration ID (contains "/"). +type MigrateCommand struct { + Meta +} + +func (c *MigrateCommand) Run(args []string) int { + // If the first arg looks like a migration ID (contains /), delegate + // directly to the apply command for convenience. + if len(args) > 0 && strings.Contains(args[0], "/") { + apply := &MigrateApplyCommand{Meta: c.Meta} + return apply.Run(args) + } + + return cli.RunResultHelp +} + +func (c *MigrateCommand) Help() string { + helpText := ` +Usage: terraform [global options] migrate [options] [args] + + This command has subcommands for running source code migrations. + + Migrations transform your Terraform configuration files to accommodate + breaking changes introduced by provider upgrades. Available subcommands + include: + + list List available migrations for the current working directory + Apply a specific migration (e.g. hashicorp/aws/v3-to-v4) + +` + return strings.TrimSpace(helpText) +} + +func (c *MigrateCommand) Synopsis() string { + return "Run source code migrations" +} diff --git a/internal/command/migrate_list.go b/internal/command/migrate_list.go new file mode 100644 index 0000000000..545629a6c6 --- /dev/null +++ b/internal/command/migrate_list.go @@ -0,0 +1,80 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/migrate" + "github.com/hashicorp/terraform/internal/command/views" +) + +// MigrateListCommand is a Command implementation that lists available +// migrations for the current working directory. +type MigrateListCommand struct { + Meta +} + +func (c *MigrateListCommand) Run(rawArgs []string) int { + // Process global flags + rawArgs = c.Meta.process(rawArgs) + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) + + // Parse command flags + args, diags := arguments.ParseMigrateList(rawArgs) + if diags.HasErrors() { + c.View.Diagnostics(diags) + c.View.HelpPrompt("migrate list") + return 1 + } + + // Create view + view := views.NewMigrateList(args.ViewType, c.View) + + // Working directory + dir := "." + + // Get all migrations from registry + registry := migrate.NewRegistry() + all := registry.All() + + // Run engine to find matches (dry-run style) + resultsByMigration := make(map[string][]migrate.SubMigrationResult) + for _, m := range all { + results, err := migrate.Apply(dir, m) + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + if len(results) > 0 { + resultsByMigration[m.ID()] = results + } + } + + // Render + return view.List(all, resultsByMigration, args.Detail) +} + +func (c *MigrateListCommand) Help() string { + helpText := ` +Usage: terraform [global options] migrate list [options] + + Lists available migrations for the Terraform configuration in the + current working directory. + +Options: + + -detail Show all sub-migrations, not just a summary. + + -json Output the migration list in a machine-readable JSON format. +` + return strings.TrimSpace(helpText) +} + +func (c *MigrateListCommand) Synopsis() string { + return "List available migrations" +} diff --git a/internal/command/migrate_list_test.go b/internal/command/migrate_list_test.go new file mode 100644 index 0000000000..21668e1b69 --- /dev/null +++ b/internal/command/migrate_list_test.go @@ -0,0 +1,63 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMigrateList(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "main.tf"), []byte(`resource "aws_s3_bucket" "test" { + bucket = "my-bucket" + acl = "private" +} +`), 0644) + + t.Chdir(dir) + + view, done := testView(t) + c := &MigrateListCommand{ + Meta: Meta{View: view}, + } + + code := c.Run(nil) + if code != 0 { + t.Fatalf("exit code %d", code) + } + + output := done(t) + got := output.Stdout() + if !strings.Contains(got, "hashicorp/aws") { + t.Errorf("expected hashicorp/aws in output:\n%s", got) + } +} + +func TestMigrateList_noMatches(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "main.tf"), []byte(`resource "null_resource" "test" { +} +`), 0644) + + t.Chdir(dir) + + view, done := testView(t) + c := &MigrateListCommand{ + Meta: Meta{View: view}, + } + + code := c.Run(nil) + if code != 0 { + t.Fatalf("exit code %d", code) + } + + output := done(t) + got := output.Stdout() + if !strings.Contains(got, "No applicable migrations") { + t.Errorf("expected 'No applicable migrations' in output:\n%s", got) + } +}