From 464cd51eaeb65466bfbb2da7b457fd49bb0a55a4 Mon Sep 17 00:00:00 2001 From: Luke Kysow Date: Thu, 22 Mar 2018 13:36:56 -0700 Subject: [PATCH] Implement plan step. --- server/events/atlantisyaml/apply_step_test.go | 8 +- server/events/atlantisyaml/plan_step.go | 94 ++++++ server/events/atlantisyaml/plan_step_test.go | 285 ++++++++++++++++++ 3 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 server/events/atlantisyaml/plan_step.go create mode 100644 server/events/atlantisyaml/plan_step_test.go diff --git a/server/events/atlantisyaml/apply_step_test.go b/server/events/atlantisyaml/apply_step_test.go index 081774ace..0502ab2e1 100644 --- a/server/events/atlantisyaml/apply_step_test.go +++ b/server/events/atlantisyaml/apply_step_test.go @@ -31,7 +31,7 @@ func TestRun_NoDir(t *testing.T) { } func TestRun_NoPlanFile(t *testing.T) { - tmpDir, cleanup := applyTestTmpDir(t) + tmpDir, cleanup := tmpDir_stepTests(t) defer cleanup() s := atlantisyaml.ApplyStep{ @@ -49,7 +49,7 @@ func TestRun_NoPlanFile(t *testing.T) { } func TestRun_Success(t *testing.T) { - tmpDir, cleanup := applyTestTmpDir(t) + tmpDir, cleanup := tmpDir_stepTests(t) defer cleanup() planPath := filepath.Join(tmpDir, "workspace.tfplan") err := ioutil.WriteFile(planPath, nil, 0644) @@ -80,9 +80,9 @@ func TestRun_Success(t *testing.T) { terraform.VerifyWasCalledOnce().RunCommandWithVersion(nil, tmpDir, []string{"apply", "-no-color", "extra", "args", "comment", "args", planPath}, tfVersion, "workspace") } -// applyTestTmpDir creates a temporary directory and returns its path along +// tmpDir_stepTests creates a temporary directory and returns its path along // with a cleanup function to be called via defer. -func applyTestTmpDir(t *testing.T) (string, func()) { +func tmpDir_stepTests(t *testing.T) (string, func()) { tmpDir, err := ioutil.TempDir("", "") Ok(t, err) return tmpDir, func() { os.RemoveAll(tmpDir) } diff --git a/server/events/atlantisyaml/plan_step.go b/server/events/atlantisyaml/plan_step.go new file mode 100644 index 000000000..646506381 --- /dev/null +++ b/server/events/atlantisyaml/plan_step.go @@ -0,0 +1,94 @@ +package atlantisyaml + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// atlantisUserTFVar is the name of the variable we execute terraform +// with, containing the vcs username of who is running the command +const atlantisUserTFVar = "atlantis_user" +const defaultWorkspace = "default" + +// PlanStep runs `terraform plan`. +type PlanStep struct { + ExtraArgs []string + TerraformExecutor TerraformExec + Meta StepMeta +} + +func (p *PlanStep) Run() (string, error) { + // We only need to switch workspaces in version 0.9.*. In older versions, + // there is no such thing as a workspace so we don't need to do anything. + // In newer versions, the TF_WORKSPACE env var is respected and will handle + // using the right workspace and even creating it if it doesn't exist. + // This variable is set inside the Terraform executor. + if err := p.switchWorkspace(); err != nil { + return "", err + } + + planFile := filepath.Join(p.Meta.AbsolutePath, fmt.Sprintf("%s.tfplan", p.Meta.Workspace)) + userVar := fmt.Sprintf("%s=%s", atlantisUserTFVar, p.Meta.Username) + tfPlanCmd := append(append([]string{"plan", "-refresh", "-no-color", "-out", planFile, "-var", userVar}, p.ExtraArgs...), p.Meta.ExtraCommentArgs...) + + // Check if env/{workspace}.tfvars exist and include it. This is a use-case + // from Hootsuite where Atlantis was first created so we're keeping this as + // an homage and a favor so they don't need to refactor all their repos. + // It's also a nice way to structure your repos to reduce duplication. + optionalEnvFile := filepath.Join(p.Meta.AbsolutePath, "env", p.Meta.Workspace+".tfvars") + if _, err := os.Stat(optionalEnvFile); err == nil { + tfPlanCmd = append(tfPlanCmd, "-var-file", optionalEnvFile) + } + + return p.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, filepath.Join(p.Meta.AbsolutePath), tfPlanCmd, p.Meta.TerraformVersion, p.Meta.Workspace) +} + +// switchWorkspace changes the terraform workspace if necessary and will create +// it if it doesn't exist. It handles differences between versions. +func (p *PlanStep) switchWorkspace() error { + // In versions less than 0.9 there is no support for workspaces. + noWorkspaceSupport := MustConstraint("<0.9").Check(p.Meta.TerraformVersion) + if noWorkspaceSupport && p.Meta.Workspace != defaultWorkspace { + return fmt.Errorf("terraform version %s does not support workspaces", p.Meta.TerraformVersion) + } + if noWorkspaceSupport { + return nil + } + + // In version 0.9.* the workspace command was called env. + workspaceCmd := "workspace" + runningZeroPointNine := MustConstraint(">=0.9,<0.10").Check(p.Meta.TerraformVersion) + if runningZeroPointNine { + workspaceCmd = "env" + } + + // Use `workspace show` to find out what workspace we're in now. If we're + // already in the right workspace then no need to switch. This will save us + // about ten seconds. This command is only available in > 0.10. + if !runningZeroPointNine { + workspaceShowOutput, err := p.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, p.Meta.AbsolutePath, []string{workspaceCmd, "show"}, p.Meta.TerraformVersion, p.Meta.Workspace) + if err != nil { + return err + } + // If `show` says we're already on this workspace then we're done. + if strings.TrimSpace(workspaceShowOutput) == p.Meta.Workspace { + return nil + } + } + + // Finally we'll have to select the workspace. Although we end up running + // with TF_WORKSPACE set, we need to figure out if this workspace exists so + // we can create it if it doesn't. To do this we can either select and catch + // the error or use list and then look for the workspace. Both commands take + // the same amount of time so that's why we're running select here. + _, err := p.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, p.Meta.AbsolutePath, []string{workspaceCmd, "select", "-no-color", p.Meta.Workspace}, p.Meta.TerraformVersion, p.Meta.Workspace) + if err != nil { + // If terraform workspace select fails we run terraform workspace + // new to create a new workspace automatically. + _, err = p.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, p.Meta.AbsolutePath, []string{workspaceCmd, "new", "-no-color", p.Meta.Workspace}, p.Meta.TerraformVersion, p.Meta.Workspace) + return err + } + return nil +} diff --git a/server/events/atlantisyaml/plan_step_test.go b/server/events/atlantisyaml/plan_step_test.go new file mode 100644 index 000000000..d5d750299 --- /dev/null +++ b/server/events/atlantisyaml/plan_step_test.go @@ -0,0 +1,285 @@ +package atlantisyaml_test + +import ( + "errors" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/go-version" + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events/atlantisyaml" + "github.com/runatlantis/atlantis/server/events/mocks/matchers" + matchers2 "github.com/runatlantis/atlantis/server/events/run/mocks/matchers" + "github.com/runatlantis/atlantis/server/events/terraform/mocks" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +func TestRun_NoWorkspaceIn08(t *testing.T) { + // We don't want any workspace commands to be run in 0.8. + RegisterMockTestingT(t) + terraform := mocks.NewMockClient() + + tfVersion, _ := version.NewVersion("0.8") + logger := logging.NewNoopLogger() + workspace := "default" + s := atlantisyaml.PlanStep{ + Meta: atlantisyaml.StepMeta{ + Log: logger, + Workspace: workspace, + AbsolutePath: "/path", + DirRelativeToRepoRoot: ".", + TerraformVersion: tfVersion, + ExtraCommentArgs: []string{"comment", "args"}, + Username: "username", + }, + ExtraArgs: []string{"extra", "args"}, + TerraformExecutor: terraform, + } + + When(terraform.RunCommandWithVersion(matchers.AnyPtrToLoggingSimpleLogger(), AnyString(), AnyStringSlice(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + ThenReturn("output", nil) + output, err := s.Run() + Ok(t, err) + + Equals(t, "output", output) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", []string{"plan", "-refresh", "-no-color", "-out", "/path/default.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"}, tfVersion, workspace) + + // Verify that no env or workspace commands were run + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, "/path", []string{"env", "select", "-no-color", "workspace"}, tfVersion, workspace) + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, "/path", []string{"workspace", "select", "-no-color", "workspace"}, tfVersion, workspace) +} + +func TestRun_ErrWorkspaceIn08(t *testing.T) { + // If they attempt to use a workspace other than default in 0.8 + // we should error. + RegisterMockTestingT(t) + terraform := mocks.NewMockClient() + + tfVersion, _ := version.NewVersion("0.8") + logger := logging.NewNoopLogger() + workspace := "notdefault" + s := atlantisyaml.PlanStep{ + Meta: atlantisyaml.StepMeta{ + Log: logger, + Workspace: workspace, + AbsolutePath: "/path", + DirRelativeToRepoRoot: ".", + TerraformVersion: tfVersion, + ExtraCommentArgs: []string{"comment", "args"}, + Username: "username", + }, + ExtraArgs: []string{"extra", "args"}, + TerraformExecutor: terraform, + } + + When(terraform.RunCommandWithVersion(matchers.AnyPtrToLoggingSimpleLogger(), AnyString(), AnyStringSlice(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + ThenReturn("output", nil) + _, err := s.Run() + ErrEquals(t, "terraform version 0.8.0 does not support workspaces", err) +} + +func TestRun_SwitchesWorkspace(t *testing.T) { + RegisterMockTestingT(t) + + cases := []struct { + tfVersion string + expWorkspaceCmd string + }{ + { + "0.9.0", + "env", + }, + { + "0.9.11", + "env", + }, + { + "0.10.0", + "workspace", + }, + { + "0.11.0", + "workspace", + }, + } + + for _, c := range cases { + t.Run(c.tfVersion, func(t *testing.T) { + terraform := mocks.NewMockClient() + + tfVersion, _ := version.NewVersion(c.tfVersion) + logger := logging.NewNoopLogger() + s := atlantisyaml.PlanStep{ + Meta: atlantisyaml.StepMeta{ + Log: logger, + Workspace: "workspace", + AbsolutePath: "/path", + DirRelativeToRepoRoot: ".", + TerraformVersion: tfVersion, + ExtraCommentArgs: []string{"comment", "args"}, + Username: "username", + }, + ExtraArgs: []string{"extra", "args"}, + TerraformExecutor: terraform, + } + + When(terraform.RunCommandWithVersion(matchers.AnyPtrToLoggingSimpleLogger(), AnyString(), AnyStringSlice(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + ThenReturn("output", nil) + output, err := s.Run() + Ok(t, err) + + Equals(t, "output", output) + // Verify that env select was called as well as plan. + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", []string{c.expWorkspaceCmd, "select", "-no-color", "workspace"}, tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", []string{"plan", "-refresh", "-no-color", "-out", "/path/workspace.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"}, tfVersion, "workspace") + }) + } +} + +func TestRun_CreatesWorkspace(t *testing.T) { + // Test that if `workspace select` fails, we call `workspace new`. + RegisterMockTestingT(t) + + cases := []struct { + tfVersion string + expWorkspaceCommand string + }{ + { + "0.9.0", + "env", + }, + { + "0.9.11", + "env", + }, + { + "0.10.0", + "workspace", + }, + { + "0.11.0", + "workspace", + }, + } + + for _, c := range cases { + t.Run(c.tfVersion, func(t *testing.T) { + terraform := mocks.NewMockClient() + tfVersion, _ := version.NewVersion(c.tfVersion) + logger := logging.NewNoopLogger() + s := atlantisyaml.PlanStep{ + Meta: atlantisyaml.StepMeta{ + Log: logger, + Workspace: "workspace", + AbsolutePath: "/path", + DirRelativeToRepoRoot: ".", + TerraformVersion: tfVersion, + ExtraCommentArgs: []string{"comment", "args"}, + Username: "username", + }, + ExtraArgs: []string{"extra", "args"}, + TerraformExecutor: terraform, + } + + // Ensure that we actually try to switch workspaces by making the + // output of `workspace show` to be a different name. + When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, tfVersion, "workspace")).ThenReturn("diffworkspace\n", nil) + + expWorkspaceArgs := []string{c.expWorkspaceCommand, "select", "-no-color", "workspace"} + When(terraform.RunCommandWithVersion(logger, "/path", expWorkspaceArgs, tfVersion, "workspace")).ThenReturn("", errors.New("workspace does not exist")) + + expPlanArgs := []string{"plan", "-refresh", "-no-color", "-out", "/path/workspace.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"} + When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "workspace")).ThenReturn("output", nil) + + output, err := s.Run() + Ok(t, err) + + Equals(t, "output", output) + // Verify that env select was called as well as plan. + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expWorkspaceArgs, tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "workspace") + }) + } +} + +func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { + // Tests that if workspace show says we're on the right workspace we don't + // switch. + RegisterMockTestingT(t) + terraform := mocks.NewMockClient() + tfVersion, _ := version.NewVersion("0.10.0") + logger := logging.NewNoopLogger() + s := atlantisyaml.PlanStep{ + Meta: atlantisyaml.StepMeta{ + Log: logger, + Workspace: "workspace", + AbsolutePath: "/path", + DirRelativeToRepoRoot: ".", + TerraformVersion: tfVersion, + ExtraCommentArgs: []string{"comment", "args"}, + Username: "username", + }, + ExtraArgs: []string{"extra", "args"}, + TerraformExecutor: terraform, + } + + When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, tfVersion, "workspace")).ThenReturn("workspace\n", nil) + + expPlanArgs := []string{"plan", "-refresh", "-no-color", "-out", "/path/workspace.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"} + When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "workspace")).ThenReturn("output", nil) + + output, err := s.Run() + Ok(t, err) + + Equals(t, "output", output) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "workspace") + + // Verify that workspace select was never called. + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, "/path", []string{"workspace", "select", "-no-color", "workspace"}, tfVersion, "workspace") +} + +func TestRun_AddsEnvVarFile(t *testing.T) { + // Test that if env/workspace.tfvars file exists we use -var-file option. + RegisterMockTestingT(t) + terraform := mocks.NewMockClient() + + // Create the env/workspace.tfvars file. + tmpDir, cleanup := tmpDir_stepTests(t) + defer cleanup() + err := os.MkdirAll(filepath.Join(tmpDir, "env"), 0700) + Ok(t, err) + envVarsFile := filepath.Join(tmpDir, "env/workspace.tfvars") + err = ioutil.WriteFile(envVarsFile, nil, 0644) + Ok(t, err) + + // Using version >= 0.10 here so we don't expect any env commands. + tfVersion, _ := version.NewVersion("0.10.0") + logger := logging.NewNoopLogger() + s := atlantisyaml.PlanStep{ + Meta: atlantisyaml.StepMeta{ + Log: logger, + Workspace: "workspace", + AbsolutePath: tmpDir, + DirRelativeToRepoRoot: ".", + TerraformVersion: tfVersion, + ExtraCommentArgs: []string{"comment", "args"}, + Username: "username", + }, + ExtraArgs: []string{"extra", "args"}, + TerraformExecutor: terraform, + } + + expPlanArgs := []string{"plan", "-refresh", "-no-color", "-out", filepath.Join(tmpDir, "workspace.tfplan"), "-var", "atlantis_user=username", "extra", "args", "comment", "args", "-var-file", envVarsFile} + When(terraform.RunCommandWithVersion(logger, tmpDir, expPlanArgs, tfVersion, "workspace")).ThenReturn("output", nil) + + output, err := s.Run() + Ok(t, err) + + Equals(t, "output", output) + // Verify that env select was never called since we're in version >= 0.10 + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, tmpDir, []string{"env", "select", "-no-color", "workspace"}, tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, expPlanArgs, tfVersion, "workspace") +}