From fa66cfd3ab598ea232e2984ba8d5ae4425c093ec Mon Sep 17 00:00:00 2001 From: Masayuki Morita Date: Wed, 28 Jul 2021 22:57:52 +0900 Subject: [PATCH 1/2] Add a new flag `--out` for saving a plan file after dry-run migrations Fixes #36 Add a new flag `--out` for saving a plan file after dry-run migrations. It almost equivalents to `terraform plan -out`, but runs after dry-run migrations. Note that the new flag is `--out`, not `-out`. This is only because the pflag library we use follows the GNU style long options. This is intuitively simple, but after implemented, it turned out it wasn't a trivial matter. The plan file internally contains terraform configurations used by plan. The tfmigrate uses a temporary local backend configuration not to change a remote state in plan phase. This means applying the plan file affects only a local state. We need the following steps to apply the plan: (1) tfmigrate plan --out=foo.tfplan tfmigrate_test.hcl (2) tfmigrate apply tfmigrate_test.hcl (3) terraform apply foo.tfplan (4) terraform state push -force terraform.tfstate (5) terraform plan -detailed-exitcode (6) rm terraform.tfstate foo.tfplan Make sure to force push the local state to remote after `terraform apply`. The `-force` flag is required in (4) because the lineage of the state will be changed. As you know, this is unsafe operation. But I couldn't find a safe way to do this in the current technical limitations. You can confirm the final plan has no changes in (5). --- command/plan.go | 6 +++ tfexec/terraform.go | 10 ++++ tfexec/terraform_plan.go | 31 ++++++------ tfexec/terraform_plan_test.go | 45 +++++++++++------ tfexec/terraform_test.go | 31 ++++++++++++ tfmigrate/config.go | 3 ++ tfmigrate/multi_state_migrator.go | 15 +++++- tfmigrate/multi_state_migrator_test.go | 70 +++++++++++++++++++++++++- tfmigrate/state_import_action_test.go | 2 +- tfmigrate/state_migrator.go | 12 ++++- tfmigrate/state_migrator_test.go | 40 ++++++++++++++- tfmigrate/state_mv_action_test.go | 2 +- tfmigrate/state_rm_action_test.go | 2 +- 13 files changed, 230 insertions(+), 39 deletions(-) diff --git a/command/plan.go b/command/plan.go index 5752a41..d1335f3 100644 --- a/command/plan.go +++ b/command/plan.go @@ -13,12 +13,14 @@ import ( // migration operations to a temporary state. type PlanCommand struct { Meta + out string } // Run runs the procedure of this command. func (c *PlanCommand) Run(args []string) int { cmdFlags := flag.NewFlagSet("plan", flag.ContinueOnError) cmdFlags.StringVar(&c.configFile, "config", defaultConfigFile, "A path to tfmigrate config file") + cmdFlags.StringVar(&c.out, "out", "", "Save a plan file after dry-run migration to the given path") if err := cmdFlags.Parse(args); err != nil { c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) @@ -33,6 +35,7 @@ func (c *PlanCommand) Run(args []string) int { log.Printf("[DEBUG] [command] config: %#v\n", c.config) c.Option = newOption() + c.Option.PlanOut = c.out // The option may contains sensitive values such as environment variables. // So logging the option set log level to DEBUG instead of INFO. log.Printf("[DEBUG] [command] option: %#v\n", c.Option) @@ -111,6 +114,9 @@ Arguments: Options: --config A path to tfmigrate config file + --out=path Save a plan file after dry-run migration to the given path. + Note that applying the plan file only affects a local state, + make sure to force push it to remote after terraform apply. ` return strings.TrimSpace(helpText) } diff --git a/tfexec/terraform.go b/tfexec/terraform.go index 5fced15..841660d 100644 --- a/tfexec/terraform.go +++ b/tfexec/terraform.go @@ -320,3 +320,13 @@ func hasPrefixOptions(opts []string, prefix string) bool { } return false } + +// getOptionValue returns a value if any element in a list of options has a given prefix. +func getOptionValue(opts []string, prefix string) string { + for _, opt := range opts { + if strings.HasPrefix(opt, prefix) { + return opt[len(prefix):] + } + } + return "" +} diff --git a/tfexec/terraform_plan.go b/tfexec/terraform_plan.go index 1d074b8..7c88f6d 100644 --- a/tfexec/terraform_plan.go +++ b/tfexec/terraform_plan.go @@ -24,21 +24,24 @@ func (c *terraformCLI) Plan(ctx context.Context, state *State, dir string, opts args = append(args, "-state="+tmpState.Name()) } - // disallow -out option for writing a plan file to a temporary file and load it to memory + // To return a plan file as a return value, we always use an -out option and load it to memory. + // if the option exists just use it else create a temporary file. + planOut := "" if hasPrefixOptions(opts, "-out=") { - return nil, fmt.Errorf("failed to build options. The -out= option is not allowed. Read a return value: %v", opts) - } - - tmpPlan, err := ioutil.TempFile("", "tfplan") - if err != nil { - return nil, fmt.Errorf("failed to create temporary plan file: %s", err) - } - defer os.Remove(tmpPlan.Name()) + planOut = getOptionValue(opts, "-out=") + } else { + tmpPlan, err := ioutil.TempFile("", "tfplan") + if err != nil { + return nil, fmt.Errorf("failed to create temporary plan file: %s", err) + } + planOut = tmpPlan.Name() + defer os.Remove(planOut) - if err := tmpPlan.Close(); err != nil { - return nil, fmt.Errorf("failed to close temporary plan file: %s", err) + if err := tmpPlan.Close(); err != nil { + return nil, fmt.Errorf("failed to close temporary plan file: %s", err) + } + args = append(args, "-out="+planOut) } - args = append(args, "-out="+tmpPlan.Name()) args = append(args, opts...) @@ -46,11 +49,11 @@ func (c *terraformCLI) Plan(ctx context.Context, state *State, dir string, opts args = append(args, dir) } - _, _, err = c.Run(ctx, args...) + _, _, err := c.Run(ctx, args...) // terraform plan -detailed-exitcode returns 2 if there is a diff. // So we intentionally ignore an error of read the plan file and returns the // original error of terraform plan command. - plan, _ := ioutil.ReadFile(tmpPlan.Name()) + plan, _ := ioutil.ReadFile(planOut) return NewPlan(plan), err } diff --git a/tfexec/terraform_plan_test.go b/tfexec/terraform_plan_test.go index 1cbf0f6..08f507f 100644 --- a/tfexec/terraform_plan_test.go +++ b/tfexec/terraform_plan_test.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "io/ioutil" + "os" + "path/filepath" "reflect" "regexp" "strings" @@ -139,22 +141,6 @@ func TestTerraformCLIPlan(t *testing.T) { want: nil, ok: false, }, - { - desc: "with -out= (conflict error)", - mockCommands: []*mockCommand{ - { - args: []string{"terraform", "plan", "-state=/path/to/tempfile", "-out=/path/to/planfile", "-input=false", "-out=foo.tfplan", "foo"}, - argsRe: regexp.MustCompile(`^terraform plan -state=.+ -out=\S+ -input=false -no-color -out=foo.tfplan foo$`), - runFunc: runFunc, - exitCode: 0, - }, - }, - dir: "foo", - opts: []string{"-input=false", "-out=foo.tfplan"}, - state: state, - want: nil, - ok: false, - }, } for _, tc := range cases { @@ -196,3 +182,30 @@ func TestAccTerraformCLIPlan(t *testing.T) { t.Error("plan success but returns nil") } } + +func TestAccTerraformCLIPlanWithOut(t *testing.T) { + SkipUnlessAcceptanceTestEnabled(t) + + source := `resource "null_resource" "foo" {}` + e := SetupTestAcc(t, source) + terraformCLI := NewTerraformCLI(e) + + err := terraformCLI.Init(context.Background(), "", "-input=false", "-no-color") + if err != nil { + t.Fatalf("failed to run terraform init: %s", err) + } + + planOut := "foo.tfplan" + plan, err := terraformCLI.Plan(context.Background(), nil, "", "-input=false", "-no-color", "-out="+planOut) + if err != nil { + t.Fatalf("failed to run terraform plan: %s", err) + } + + if plan == nil { + t.Error("plan success but returns nil") + } + + if _, err := os.Stat(filepath.Join(e.Dir(), planOut)); os.IsNotExist(err) { + t.Errorf("failed to find a plan file: %s, err %s", planOut, err) + } +} diff --git a/tfexec/terraform_test.go b/tfexec/terraform_test.go index 2c78cbf..1d3d29c 100644 --- a/tfexec/terraform_test.go +++ b/tfexec/terraform_test.go @@ -196,3 +196,34 @@ func TestAccTerraformCLIPlanHasChange(t *testing.T) { t.Fatalf("expect to have changes") } } + +func TestGetOptionValue(t *testing.T) { + cases := []struct { + desc string + opts []string + prefix string + want string + }{ + { + desc: "found", + opts: []string{"-input=false", "-no-color", "-out=foo.tfplan", "-detailed-exitcode"}, + prefix: "-out=", + want: "foo.tfplan", + }, + { + desc: "not found", + opts: []string{"-input=false", "-no-color", "-detailed-exitcode"}, + prefix: "-out=", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + got := getOptionValue(tc.opts, tc.prefix) + if got != tc.want { + t.Errorf("got: %s, want: %s", got, tc.want) + } + }) + } +} diff --git a/tfmigrate/config.go b/tfmigrate/config.go index 1d0389b..2ccb4d3 100644 --- a/tfmigrate/config.go +++ b/tfmigrate/config.go @@ -24,4 +24,7 @@ type MigratorOption struct { // It's intended to inject a wrapper command such as direnv. // e.g.) direnv exec . terraform ExecPath string + + // PlanOut is a path to plan file to be saved. + PlanOut string } diff --git a/tfmigrate/multi_state_migrator.go b/tfmigrate/multi_state_migrator.go index d6e0dd8..f22a1fd 100644 --- a/tfmigrate/multi_state_migrator.go +++ b/tfmigrate/multi_state_migrator.go @@ -71,6 +71,9 @@ type MultiStateMigrator struct { toWorkspace string // actions is a list of multi state migration operations. actions []MultiStateAction + // o is an option for migrator. + // It is used for shared settings across Migrator instances. + o *MigratorOption // force operation in case of unexpected diff force bool } @@ -92,6 +95,7 @@ func NewMultiStateMigrator(fromDir string, toDir string, fromWorkspace string, t fromWorkspace: fromWorkspace, toWorkspace: toWorkspace, actions: actions, + o: o, force: force, } } @@ -127,9 +131,16 @@ func (m *MultiStateMigrator) plan(ctx context.Context) (*tfexec.State, *tfexec.S fromCurrentState = tfexec.NewState(fromNewState.Bytes()) toCurrentState = tfexec.NewState(toNewState.Bytes()) } + + // build plan options + planOpts := []string{"-input=false", "-no-color", "-detailed-exitcode"} + if m.o.PlanOut != "" { + planOpts = append(planOpts, "-out="+m.o.PlanOut) + } + // check if a plan in fromDir has no changes. log.Printf("[INFO] [migrator@%s] check diffs\n", m.fromTf.Dir()) - _, err = m.fromTf.Plan(ctx, fromCurrentState, "", "-input=false", "-no-color", "-detailed-exitcode") + _, err = m.fromTf.Plan(ctx, fromCurrentState, "", planOpts...) if err != nil { if exitErr, ok := err.(tfexec.ExitError); ok && exitErr.ExitCode() == 2 { if m.force { @@ -144,7 +155,7 @@ func (m *MultiStateMigrator) plan(ctx context.Context) (*tfexec.State, *tfexec.S // check if a plan in toDir has no changes. log.Printf("[INFO] [migrator@%s] check diffs\n", m.toTf.Dir()) - _, err = m.toTf.Plan(ctx, toCurrentState, "", "-input=false", "-no-color", "-detailed-exitcode") + _, err = m.toTf.Plan(ctx, toCurrentState, "", planOpts...) if err != nil { if exitErr, ok := err.(tfexec.ExitError); ok && exitErr.ExitCode() == 2 { if m.force { diff --git a/tfmigrate/multi_state_migrator_test.go b/tfmigrate/multi_state_migrator_test.go index 20930f7..b8f594f 100644 --- a/tfmigrate/multi_state_migrator_test.go +++ b/tfmigrate/multi_state_migrator_test.go @@ -2,6 +2,8 @@ package tfmigrate import ( "context" + "io/ioutil" + "path/filepath" "reflect" "sort" "testing" @@ -238,11 +240,13 @@ func TestAccMultiStateMigratorApply(t *testing.T) { for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { ctx := context.Background() + //setup the initial files and states fromBackend := tfexec.GetTestAccBackendS3Config(t.Name() + "/fromDir") fromTf := tfexec.SetupTestAccWithApply(t, tc.fromWorkspace, fromBackend+tc.fromSource) toBackend := tfexec.GetTestAccBackendS3Config(t.Name() + "/toDir") toTf := tfexec.SetupTestAccWithApply(t, tc.toWorkspace, toBackend+tc.toSource) + //update terraform resource files for migration tfexec.UpdateTestAccSource(t, fromTf, fromBackend+tc.fromUpdatedSource) tfexec.UpdateTestAccSource(t, toTf, toBackend+tc.toUpdatedSource) @@ -260,6 +264,7 @@ func TestAccMultiStateMigratorApply(t *testing.T) { if !changed { t.Fatalf("expect to have changes in toDir") } + //perform state migration actions := []MultiStateAction{} for _, cmdStr := range tc.actions { @@ -269,7 +274,13 @@ func TestAccMultiStateMigratorApply(t *testing.T) { } actions = append(actions, action) } - m := NewMultiStateMigrator(fromTf.Dir(), toTf.Dir(), tc.fromWorkspace, tc.toWorkspace, actions, nil, tc.force) + + o := &MigratorOption{} + if tc.force { + o.PlanOut = "foo.tfplan" + } + + m := NewMultiStateMigrator(fromTf.Dir(), toTf.Dir(), tc.fromWorkspace, tc.toWorkspace, actions, o, tc.force) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) @@ -279,6 +290,7 @@ func TestAccMultiStateMigratorApply(t *testing.T) { if err != nil { t.Fatalf("failed to run migrator apply: %s", err) } + //verify state migration results fromGot, err := fromTf.StateList(ctx, nil, nil) if err != nil { @@ -312,6 +324,62 @@ func TestAccMultiStateMigratorApply(t *testing.T) { if changed != tc.toStateExpectChange { t.Fatalf("expected change in toDir is %t but actual value is %t", tc.toStateExpectChange, changed) } + + if tc.force { + // apply the saved plan files + fromPlan, err := ioutil.ReadFile(filepath.Join(fromTf.Dir(), o.PlanOut)) + if err != nil { + t.Fatalf("failed to read a saved plan file in fromDir: %s", err) + } + err = fromTf.Apply(ctx, tfexec.NewPlan(fromPlan), "", "-input=false", "-no-color") + if err != nil { + t.Fatalf("failed to apply the saved plan file in fromDir: %s", err) + } + toPlan, err := ioutil.ReadFile(filepath.Join(toTf.Dir(), o.PlanOut)) + if err != nil { + t.Fatalf("failed to read a saved plan file in toDir: %s", err) + } + err = toTf.Apply(ctx, tfexec.NewPlan(toPlan), "", "-input=false", "-no-color") + if err != nil { + t.Fatalf("failed to apply the saved plan file in toDir: %s", err) + } + + // Note that applying the plan file only affects a local state, + // make sure to force push it to remote after terraform apply. + // The -force flag is required here because the lineage of the state was changed. + fromState, err := ioutil.ReadFile(filepath.Join(fromTf.Dir(), "terraform.tfstate")) + if err != nil { + t.Fatalf("failed to read a local state file in fromDir: %s", err) + } + err = fromTf.StatePush(ctx, tfexec.NewState(fromState), "-force") + if err != nil { + t.Fatalf("failed to force push the local state in fromDir: %s", err) + } + toState, err := ioutil.ReadFile(filepath.Join(toTf.Dir(), "terraform.tfstate")) + if err != nil { + t.Fatalf("failed to read a local state file in toDir: %s", err) + } + err = toTf.StatePush(ctx, tfexec.NewState(toState), "-force") + if err != nil { + t.Fatalf("failed to force push the local state in toDir: %s", err) + } + + // confirm no changes + changed, err := fromTf.PlanHasChange(ctx, nil, "") + if err != nil { + t.Fatalf("failed to run PlanHasChange in fromDir: %s", err) + } + if changed { + t.Fatalf("expect not to have changes in fromDir") + } + changed, err = toTf.PlanHasChange(ctx, nil, "") + if err != nil { + t.Fatalf("failed to run PlanHasChange in toDir: %s", err) + } + if changed { + t.Fatalf("expect not to have changes in toDir") + } + } }) } } diff --git a/tfmigrate/state_import_action_test.go b/tfmigrate/state_import_action_test.go index 0fc73b9..5af0b1e 100644 --- a/tfmigrate/state_import_action_test.go +++ b/tfmigrate/state_import_action_test.go @@ -44,7 +44,7 @@ resource "aws_iam_user" "baz" { NewStateImportAction("aws_iam_user.baz", "baz"), } - m := NewStateMigrator(tf.Dir(), actions, nil, false) + m := NewStateMigrator(tf.Dir(), actions, &MigratorOption{}, false) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) diff --git a/tfmigrate/state_migrator.go b/tfmigrate/state_migrator.go index a456584..88e185f 100644 --- a/tfmigrate/state_migrator.go +++ b/tfmigrate/state_migrator.go @@ -63,6 +63,9 @@ type StateMigrator struct { tf tfexec.TerraformCLI // actions is a list of state migration operations. actions []StateAction + // o is an option for migrator. + // It is used for shared settings across Migrator instances. + o *MigratorOption // force operation in case of unexpected diff force bool } @@ -80,6 +83,7 @@ func NewStateMigrator(dir string, actions []StateAction, o *MigratorOption, forc return &StateMigrator{ tf: tf, actions: actions, + o: o, force: force, } } @@ -108,8 +112,14 @@ func (m *StateMigrator) plan(ctx context.Context) (*tfexec.State, error) { currentState = tfexec.NewState(newState.Bytes()) } + // build plan options + planOpts := []string{"-input=false", "-no-color", "-detailed-exitcode"} + if m.o.PlanOut != "" { + planOpts = append(planOpts, "-out="+m.o.PlanOut) + } + log.Printf("[INFO] [migrator@%s] check diffs\n", m.tf.Dir()) - _, err = m.tf.Plan(ctx, currentState, "", "-input=false", "-no-color", "-detailed-exitcode") + _, err = m.tf.Plan(ctx, currentState, "", planOpts...) if err != nil { if exitErr, ok := err.(tfexec.ExitError); ok && exitErr.ExitCode() == 2 { if m.force { diff --git a/tfmigrate/state_migrator_test.go b/tfmigrate/state_migrator_test.go index 44aaad5..46e4f2f 100644 --- a/tfmigrate/state_migrator_test.go +++ b/tfmigrate/state_migrator_test.go @@ -2,6 +2,8 @@ package tfmigrate import ( "context" + "io/ioutil" + "path/filepath" "reflect" "sort" "testing" @@ -146,7 +148,7 @@ resource "aws_iam_user" "qux" { NewStateImportAction("aws_iam_user.qux", "qux"), } - m := NewStateMigrator(tf.Dir(), actions, nil, false) + m := NewStateMigrator(tf.Dir(), actions, &MigratorOption{}, false) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) @@ -214,7 +216,10 @@ resource "aws_security_group" "baz" {} NewStateMvAction("aws_security_group.foo", "aws_security_group.foo2"), } - m := NewStateMigrator(tf.Dir(), actions, nil, true) + o := &MigratorOption{} + o.PlanOut = "foo.tfplan" + + m := NewStateMigrator(tf.Dir(), actions, o, true) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) @@ -247,4 +252,35 @@ resource "aws_security_group" "baz" {} if !changed { t.Fatalf("expect to have changes") } + + // apply the saved plan files + plan, err := ioutil.ReadFile(filepath.Join(tf.Dir(), o.PlanOut)) + if err != nil { + t.Fatalf("failed to read a saved plan file: %s", err) + } + err = tf.Apply(ctx, tfexec.NewPlan(plan), "", "-input=false", "-no-color") + if err != nil { + t.Fatalf("failed to apply the saved plan file: %s", err) + } + + // Note that applying the plan file only affects a local state, + // make sure to force push it to remote after terraform apply. + // The -force flag is required here because the lineage of the state was changed. + state, err := ioutil.ReadFile(filepath.Join(tf.Dir(), "terraform.tfstate")) + if err != nil { + t.Fatalf("failed to read a local state file: %s", err) + } + err = tf.StatePush(ctx, tfexec.NewState(state), "-force") + if err != nil { + t.Fatalf("failed to force push the local state: %s", err) + } + + // confirm no changes + changed, err = tf.PlanHasChange(ctx, nil, "") + if err != nil { + t.Fatalf("failed to run PlanHasChange: %s", err) + } + if changed { + t.Fatalf("expect not to have changes") + } } diff --git a/tfmigrate/state_mv_action_test.go b/tfmigrate/state_mv_action_test.go index 3cae594..3aa5878 100644 --- a/tfmigrate/state_mv_action_test.go +++ b/tfmigrate/state_mv_action_test.go @@ -41,7 +41,7 @@ resource "aws_security_group" "baz" {} NewStateMvAction("aws_security_group.bar", "aws_security_group.bar2"), } - m := NewStateMigrator(tf.Dir(), actions, nil, false) + m := NewStateMigrator(tf.Dir(), actions, &MigratorOption{}, false) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) diff --git a/tfmigrate/state_rm_action_test.go b/tfmigrate/state_rm_action_test.go index 4b22d8d..dd7ef16 100644 --- a/tfmigrate/state_rm_action_test.go +++ b/tfmigrate/state_rm_action_test.go @@ -40,7 +40,7 @@ resource "aws_security_group" "baz" {} NewStateRmAction([]string{"aws_security_group.qux"}), } - m := NewStateMigrator(tf.Dir(), actions, nil, false) + m := NewStateMigrator(tf.Dir(), actions, &MigratorOption{}, false) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) From 6f610a553bbdb85c073a2e08b60caf29a3ba168d Mon Sep 17 00:00:00 2001 From: Masayuki Morita Date: Wed, 11 Aug 2021 18:35:59 +0900 Subject: [PATCH 2/2] Skip state push -force tests due to a bug in Terraform v0.12 Terraform >= v0.12.25 and < v0.13 has a bug for state push -force. https://github.com/hashicorp/terraform/issues/25761 I added a version check in acceptance tests to minimize the patch. Ideally, the version check process should be implemented in TerraformCLI instead of the test helper function because supported features may be different for some Terraform versions. I'll follow up a separated PR to add some version checks in runtime. --- go.mod | 1 + go.sum | 2 ++ tfexec/test_helper.go | 19 +++++++++++++++++++ tfmigrate/multi_state_migrator_test.go | 17 +++++++++++++++++ tfmigrate/state_migrator_test.go | 10 ++++++++++ 5 files changed, 49 insertions(+) diff --git a/go.mod b/go.mod index 1cb4b63..982b05b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/google/go-cmp v0.5.2 github.com/hashicorp/aws-sdk-go-base v0.6.0 + github.com/hashicorp/go-version v1.3.0 github.com/hashicorp/hcl/v2 v2.6.0 github.com/hashicorp/logutils v1.0.0 github.com/mattn/go-shellwords v1.0.10 diff --git a/go.sum b/go.sum index 5324ab5..8b2fa4f 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6K github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= +github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl/v2 v2.6.0 h1:3krZOfGY6SziUXa6H9PJU6TyohHn7I+ARYnhbeNBz+o= github.com/hashicorp/hcl/v2 v2.6.0/go.mod h1:bQTN5mpo+jewjJgh8jr0JUguIi7qPHUF6yIfAEN3jqY= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= diff --git a/tfexec/test_helper.go b/tfexec/test_helper.go index ba192e1..e783c95 100644 --- a/tfexec/test_helper.go +++ b/tfexec/test_helper.go @@ -11,6 +11,8 @@ import ( "runtime" "strings" "testing" + + "github.com/hashicorp/go-version" ) // mockExecutor impolements the Executor interface for testing. @@ -325,3 +327,20 @@ func UpdateTestAccSource(t *testing.T, tf TerraformCLI, source string) { t.Fatalf("failed to update source: %s", err) } } + +// MatchTerraformVersion returns true if terraform version matches a given constraints. +func MatchTerraformVersion(ctx context.Context, tf TerraformCLI, constraints string) (bool, error) { + tfVersionRaw, err := tf.Version(ctx) + if err != nil { + return false, fmt.Errorf("failed to get terraform version: %s", err) + } + v, err := version.NewVersion(tfVersionRaw) + if err != nil { + return false, fmt.Errorf("failed to parse terraform version: %s", err) + } + c, err := version.NewConstraint(constraints) + if err != nil { + return false, fmt.Errorf("failed to new version constraint: %s", err) + } + return c.Check(v), nil +} diff --git a/tfmigrate/multi_state_migrator_test.go b/tfmigrate/multi_state_migrator_test.go index b8f594f..b9a7c68 100644 --- a/tfmigrate/multi_state_migrator_test.go +++ b/tfmigrate/multi_state_migrator_test.go @@ -344,6 +344,23 @@ func TestAccMultiStateMigratorApply(t *testing.T) { t.Fatalf("failed to apply the saved plan file in toDir: %s", err) } + // Terraform >= v0.12.25 and < v0.13 has a bug for state push -force + // https://github.com/hashicorp/terraform/issues/25761 + fromTfVersionMatched, err := tfexec.MatchTerraformVersion(ctx, fromTf, ">= 0.12.25, < 0.13") + if err != nil { + t.Fatalf("failed to check terraform version constraints in fromDir: %s", err) + } + if fromTfVersionMatched { + t.Skip("skip the following test due to a bug in Terraform v0.12") + } + toTfVersionMatched, err := tfexec.MatchTerraformVersion(ctx, toTf, ">= 0.12.25, < 0.13") + if err != nil { + t.Fatalf("failed to check terraform version constraints in toDir: %s", err) + } + if toTfVersionMatched { + t.Skip("skip the following test due to a bug in Terraform v0.12") + } + // Note that applying the plan file only affects a local state, // make sure to force push it to remote after terraform apply. // The -force flag is required here because the lineage of the state was changed. diff --git a/tfmigrate/state_migrator_test.go b/tfmigrate/state_migrator_test.go index 46e4f2f..c856f74 100644 --- a/tfmigrate/state_migrator_test.go +++ b/tfmigrate/state_migrator_test.go @@ -263,6 +263,16 @@ resource "aws_security_group" "baz" {} t.Fatalf("failed to apply the saved plan file: %s", err) } + // Terraform >= v0.12.25 and < v0.13 has a bug for state push -force + // https://github.com/hashicorp/terraform/issues/25761 + tfVersionMatched, err := tfexec.MatchTerraformVersion(ctx, tf, ">= 0.12.25, < 0.13") + if err != nil { + t.Fatalf("failed to check terraform version constraints: %s", err) + } + if tfVersionMatched { + t.Skip("skip the following test due to a bug in Terraform v0.12") + } + // Note that applying the plan file only affects a local state, // make sure to force push it to remote after terraform apply. // The -force flag is required here because the lineage of the state was changed.