Skip to content

Commit

Permalink
[WIP] Add a new flag tfmigrate plan --out=foo.tfplan
Browse files Browse the repository at this point in the history
Save a plan file after dry-run migration to the given path.
  • Loading branch information
minamijoyo committed Aug 3, 2021
1 parent 61dbd79 commit 6a19d01
Show file tree
Hide file tree
Showing 13 changed files with 124 additions and 39 deletions.
4 changes: 4 additions & 0 deletions command/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
31 changes: 17 additions & 14 deletions tfexec/terraform_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,36 @@ 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...)

if len(dir) > 0 {
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
}
45 changes: 29 additions & 16 deletions tfexec/terraform_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
31 changes: 31 additions & 0 deletions tfexec/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
3 changes: 3 additions & 0 deletions tfmigrate/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 13 additions & 2 deletions tfmigrate/multi_state_migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -92,6 +95,7 @@ func NewMultiStateMigrator(fromDir string, toDir string, fromWorkspace string, t
fromWorkspace: fromWorkspace,
toWorkspace: toWorkspace,
actions: actions,
o: o,
force: force,
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion tfmigrate/multi_state_migrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tfmigrate/state_import_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion tfmigrate/state_migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -80,6 +83,7 @@ func NewStateMigrator(dir string, actions []StateAction, o *MigratorOption, forc
return &StateMigrator{
tf: tf,
actions: actions,
o: o,
force: force,
}
}
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions tfmigrate/state_migrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tfmigrate/state_mv_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tfmigrate/state_rm_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 6a19d01

Please sign in to comment.