diff --git a/command/plan.go b/command/plan.go index 5752a41..e5ffdc1 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,7 @@ Arguments: Options: --config A path to tfmigrate config file + --out=path Save a plan file after dry-run migration to the given path ` 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..f1cae9a 100644 --- a/tfmigrate/multi_state_migrator_test.go +++ b/tfmigrate/multi_state_migrator_test.go @@ -269,7 +269,7 @@ func TestAccMultiStateMigratorApply(t *testing.T) { } actions = append(actions, action) } - m := NewMultiStateMigrator(fromTf.Dir(), toTf.Dir(), tc.fromWorkspace, tc.toWorkspace, actions, nil, tc.force) + m := NewMultiStateMigrator(fromTf.Dir(), toTf.Dir(), tc.fromWorkspace, tc.toWorkspace, actions, &MigratorOption{}, tc.force) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) 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..9f36884 100644 --- a/tfmigrate/state_migrator_test.go +++ b/tfmigrate/state_migrator_test.go @@ -146,7 +146,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 +214,7 @@ resource "aws_security_group" "baz" {} NewStateMvAction("aws_security_group.foo", "aws_security_group.foo2"), } - m := NewStateMigrator(tf.Dir(), actions, nil, true) + m := NewStateMigrator(tf.Dir(), actions, &MigratorOption{}, true) err = m.Plan(ctx) if err != nil { t.Fatalf("failed to run migrator plan: %s", err) 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)