mirror of https://github.com/hashicorp/terraform
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
parent
f3f273879f
commit
062034180d
@ -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…
Reference in new issue