Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a new flag --out for saving a plan file after dry-run migrations #37

Merged
merged 2 commits into from
Aug 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 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,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)
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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)
}
})
}
}
19 changes: 19 additions & 0 deletions tfexec/test_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"runtime"
"strings"
"testing"

"github.com/hashicorp/go-version"
)

// mockExecutor impolements the Executor interface for testing.
Expand Down Expand Up @@ -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
}
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
Loading