refactor: rename migrate apply to migrate run subcommand

Changes the command structure from:
  terraform migrate <id>
to:
  terraform migrate run <id>

This gives a clearer UX with two explicit subcommands:
  terraform migrate list    — list available migrations
  terraform migrate run     — run a specific migration
prototype-migrate-ux
Daniel Schmidt 2 months ago
parent 21432988d7
commit c7498f3efd
No known key found for this signature in database
GPG Key ID: 377C3A4D62FBBBE2

@ -397,6 +397,12 @@ func initCommands(
}, nil
},
"migrate run": func() (cli.Command, error) {
return &command.MigrateRunCommand{
Meta: meta,
}, nil
},
"state": func() (cli.Command, error) {
return &command.StateCommand{}, nil
},

@ -0,0 +1,199 @@
# Terraform Migrate Subcommand — Prototype Design
## Goal
Build a prototype `terraform migrate` subcommand that migrates user source code
(`.tf` files) to accommodate breaking changes from providers or Terraform Core.
The prototype focuses on user experience for demo to customers and product people.
Transformations are implemented superficially (regex-based) while the command
infrastructure follows real Terraform patterns.
## Commands
### `terraform migrate list`
Lists all applicable migrations for the current codebase, grouped by provider.
Shows the first 3 sub-migrations per set, with a count of remaining.
```
$ terraform migrate list
hashicorp/aws (1 migration available):
v3-to-v4 Migrate AWS provider from v3 to v4 12 files, 23 changes
- s3-bucket-acl Extract ACL to aws_s3_bucket_acl
- s3-bucket-cors Extract CORS to aws_s3_bucket_cors_configuration
- s3-bucket-logging Extract logging to aws_s3_bucket_logging
(+3 more, use -detail to list all)
hashicorp/azurerm (1 migration available):
v3-to-v4 Migrate AzureRM provider from v3 to v4 5 files, 8 changes
- subnet-delegation Extract delegation to azurerm_subnet_delegation
- network-security-rule Extract rules to azurerm_network_security_rule
- storage-account-network Extract network rules to separate resource
(+1 more, use -detail to list all)
terraform (1 migration available):
v1.x-to-v2.x Update terraform block syntax 3 files, 3 changes
- required-providers-map Convert required_providers to object syntax
- backend-to-cloud Migrate backend block to cloud block
(2 sub-migrations total)
```
With `-detail`, all sub-migrations expand.
### `terraform migrate <namespace/provider/migration>`
Runs a migration set. Three modes:
**Default (no flags):** Apply immediately, show progress.
```
$ terraform migrate hashicorp/aws/v3-to-v4
Applying hashicorp/aws/v3-to-v4...
✓ s3-bucket-acl (main.tf, s3.tf)
✓ s3-bucket-cors (s3.tf)
✓ s3-bucket-logging (s3.tf)
Applied 3 changes across 2 files.
```
**`-step` flag:** Interactive per-sub-migration approval with diff shown.
```
$ terraform migrate hashicorp/aws/v3-to-v4 -step
[1/6] s3-bucket-acl: Extract ACL to aws_s3_bucket_acl
--- main.tf
+++ main.tf
@@ -12,6 +12,10 @@
resource "aws_s3_bucket" "example" {
- acl = "private"
bucket = "my-bucket"
}
+resource "aws_s3_bucket_acl" "example" {
+ bucket = aws_s3_bucket.example.id
+ acl = "private"
+}
Apply this change? [y]es / [n]o / [q]uit: y
[2/6] s3-bucket-cors: Extract CORS to aws_s3_bucket_cors_configuration
...
```
**`-dry-run` flag:** Show combined diff of all changes, then exit. No prompt, no modifications.
```
$ terraform migrate hashicorp/aws/v3-to-v4 -dry-run
Planning hashicorp/aws/v3-to-v4...
--- main.tf
+++ main.tf
@@ -12,20 +12,35 @@
... combined diff of all sub-migrations ...
--- s3.tf
+++ s3.tf
... combined diff ...
6 changes would be applied across 2 files.
```
## Architecture
### File Layout
```
commands.go # Register "migrate" and "migrate list"
internal/command/
migrate_command.go # Parent command (shows help, returns cli.RunResultHelp)
migrate_list.go # "migrate list" subcommand
migrate_apply.go # "migrate <id>" — handles default, -step, -dry-run
migrate_apply_test.go
migrate_list_test.go
internal/command/arguments/
migrate.go # ParseMigrateList, ParseMigrateApply
internal/command/views/
migrate.go # MigrateListView, MigrateApplyView (Human + JSON)
internal/command/migrate/
registry.go # Hardcoded migration registry
migration.go # Migration, SubMigration types
engine.go # Transformation engine (applies SubMigrations to files)
engine_test.go # Engine unit tests
migrations_aws.go # AWS S3 v3->v4 sample migrations
migrations_azurerm.go # Azure v3->v4 sample migrations
migrations_terraform.go # Terraform Core v1.x->v2.x sample migrations
```
### Key Types
```go
// migration.go
type Migration struct {
Namespace string // "hashicorp"
Provider string // "aws"
Name string // "v3-to-v4"
Description string
SubMigrations []SubMigration
}
func (m Migration) ID() string {
return m.Namespace + "/" + m.Provider + "/" + m.Name
}
type SubMigration struct {
Name string
Description string
Apply func(filename string, src []byte) ([]byte, error)
}
```
### Patterns Used
- **Commands** embed `Meta`, call `c.Meta.process(args)`, use `c.Meta.defaultFlagSet()`
- **Arguments** parsed via `arguments.ParseMigrateList()` / `arguments.ParseMigrateApply()`
returning typed structs, following `arguments/import.go` pattern
- **Views** follow `views/apply.go` pattern: interface with `Human` and `JSON`
implementations, constructed from `*View` base
- **Diagnostics** via `tfdiags.Diagnostics` throughout
- **Parent command** follows `state_command.go` pattern (returns `cli.RunResultHelp`)
- **Registration** in `commands.go` with `"migrate"` and `"migrate list"` keys
### Sample Migrations
1. **hashicorp/aws/v3-to-v4** — S3 bucket refactoring (real AWS provider v3->v4 change)
- `s3-bucket-acl`: Extract `acl` argument to `aws_s3_bucket_acl` resource
- `s3-bucket-cors`: Extract `cors_rule` block to `aws_s3_bucket_cors_configuration`
- `s3-bucket-logging`: Extract `logging` block to `aws_s3_bucket_logging`
2. **hashicorp/azurerm/v3-to-v4** — Azure resource extractions
- `subnet-delegation`: Extract inline delegation to `azurerm_subnet_delegation`
- `network-security-rule`: Extract inline security rules to `azurerm_network_security_rule`
3. **terraform/terraform/v1.x-to-v2.x** — Core syntax updates
- `required-providers-map`: Convert `required_providers` from map to object syntax
- `backend-to-cloud`: Migrate `backend` block to `cloud` block
### Transformation Engine
Each `SubMigration.Apply` function takes `(filename string, src []byte)` and returns
`([]byte, error)`. For the prototype, these use regex replacements to transform
HCL source. The engine iterates over `.tf` files in the working directory and
applies each sub-migration.
### Testing Strategy
- **Engine unit tests** (`engine_test.go`): Table-driven tests giving source bytes
to `SubMigration.Apply`, asserting output bytes match expected.
- **Command integration tests** (`migrate_apply_test.go`, `migrate_list_test.go`):
Following `fmt_test.go` patterns — create temp dirs with `.tf` files, run the
command, assert file contents and exit codes.
- **Mode tests**: Verify `-dry-run` does not modify files, `-step` handles y/n/q
input correctly, default mode applies all changes.

File diff suppressed because it is too large Load Diff

@ -9,23 +9,13 @@ import (
"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 "/").
// MigrateCommand is a Command implementation that shows help for
// the migrate subcommands.
type MigrateCommand struct {
Meta
}
func (c *MigrateCommand) Run(args []string) int {
// If any arg looks like a migration ID (contains / and doesn't start
// with -), delegate to the apply command.
for _, arg := range args {
if strings.Contains(arg, "/") && !strings.HasPrefix(arg, "-") {
apply := &MigrateApplyCommand{Meta: c.Meta}
return apply.Run(args)
}
}
return cli.RunResultHelp
}
@ -36,11 +26,12 @@ 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:
breaking changes introduced by provider upgrades or Terraform Core updates.
Subcommands:
list List available migrations for the current working directory
<id> Apply a specific migration (e.g. hashicorp/aws/v3-to-v4)
run Run a specific migration (e.g. terraform migrate run hashicorp/aws/v3-to-v4)
`
return strings.TrimSpace(helpText)

@ -14,13 +14,13 @@ import (
"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 {
// MigrateRunCommand is a Command implementation that runs a specific
// migration against the Terraform configuration in the current working directory.
type MigrateRunCommand struct {
Meta
}
func (c *MigrateApplyCommand) Run(rawArgs []string) int {
func (c *MigrateRunCommand) Run(rawArgs []string) int {
rawArgs = c.Meta.process(rawArgs)
common, rawArgs := arguments.ParseView(rawArgs)
// process() may have already consumed -no-color; propagate to view
@ -74,7 +74,7 @@ func (c *MigrateApplyCommand) Run(rawArgs []string) int {
}
}
func (c *MigrateApplyCommand) apply(view views.MigrateApply, dir, id string, results []migrate.SubMigrationResult) int {
func (c *MigrateRunCommand) apply(view views.MigrateApply, dir, id string, results []migrate.SubMigrationResult) int {
view.Applying(id)
// Write all results
@ -106,7 +106,7 @@ func (c *MigrateApplyCommand) apply(view views.MigrateApply, dir, id string, res
return 0
}
func (c *MigrateApplyCommand) dryRun(view views.MigrateApply, id string, results []migrate.SubMigrationResult) int {
func (c *MigrateRunCommand) dryRun(view views.MigrateApply, id string, results []migrate.SubMigrationResult) int {
view.DryRunHeader(id)
totalChanges := 0
@ -135,7 +135,7 @@ func (c *MigrateApplyCommand) dryRun(view views.MigrateApply, id string, results
return 0
}
func (c *MigrateApplyCommand) step(view views.MigrateApply, dir string, _ migrate.Migration, results []migrate.SubMigrationResult) int {
func (c *MigrateRunCommand) step(view views.MigrateApply, dir string, _ migrate.Migration, results []migrate.SubMigrationResult) int {
totalChanges := 0
allFiles := map[string]bool{}
@ -179,11 +179,11 @@ func (c *MigrateApplyCommand) step(view views.MigrateApply, dir string, _ migrat
return 0
}
func (c *MigrateApplyCommand) Help() string {
func (c *MigrateRunCommand) Help() string {
helpText := `
Usage: terraform [global options] migrate <migration-id> [options]
Usage: terraform [global options] migrate run <migration-id> [options]
Applies the specified migration to the Terraform configuration in the
Runs the specified migration against 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).
@ -199,6 +199,6 @@ Options:
return strings.TrimSpace(helpText)
}
func (c *MigrateApplyCommand) Synopsis() string {
return "Apply a specific migration"
func (c *MigrateRunCommand) Synopsis() string {
return "Run a specific migration"
}

@ -10,7 +10,7 @@ import (
"testing"
)
func TestMigrateApply_default(t *testing.T) {
func TestMigrateRun_default(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "main.tf"), []byte(`resource "aws_s3_bucket" "test" {
bucket = "my-bucket"
@ -20,7 +20,7 @@ func TestMigrateApply_default(t *testing.T) {
t.Chdir(dir)
view, done := testView(t)
c := &MigrateApplyCommand{
c := &MigrateRunCommand{
Meta: Meta{View: view},
}
@ -45,7 +45,7 @@ func TestMigrateApply_default(t *testing.T) {
}
}
func TestMigrateApply_dryRun(t *testing.T) {
func TestMigrateRun_dryRun(t *testing.T) {
dir := t.TempDir()
input := `resource "aws_s3_bucket" "test" {
bucket = "my-bucket"
@ -56,7 +56,7 @@ func TestMigrateApply_dryRun(t *testing.T) {
t.Chdir(dir)
view, done := testView(t)
c := &MigrateApplyCommand{
c := &MigrateRunCommand{
Meta: Meta{View: view},
}
@ -84,12 +84,12 @@ func TestMigrateApply_dryRun(t *testing.T) {
}
}
func TestMigrateApply_notFound(t *testing.T) {
func TestMigrateRun_notFound(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
view, done := testView(t)
c := &MigrateApplyCommand{
c := &MigrateRunCommand{
Meta: Meta{View: view},
}
@ -105,7 +105,7 @@ func TestMigrateApply_notFound(t *testing.T) {
}
}
func TestMigrateApply_noChanges(t *testing.T) {
func TestMigrateRun_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" {
@ -114,7 +114,7 @@ func TestMigrateApply_noChanges(t *testing.T) {
t.Chdir(dir)
view, done := testView(t)
c := &MigrateApplyCommand{
c := &MigrateRunCommand{
Meta: Meta{View: view},
}
Loading…
Cancel
Save