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).
prototype-migrate-ux
Daniel Schmidt 2 months ago
parent f3f273879f
commit 062034180d
No known key found for this signature in database
GPG Key ID: 377C3A4D62FBBBE2

@ -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 <migration-id> [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"
}

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

@ -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 <subcommand> [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
<id> 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"
}

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

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