Merge pull request #14 from runatlantis/comment-syntax

Add -w workspace and -d directory flags to plan/apply comments
This commit is contained in:
Luke Kysow
2018-02-26 15:15:37 -08:00
committed by GitHub
10 changed files with 427 additions and 166 deletions

View File

@@ -45,13 +45,13 @@ Read about [Why We Built Atlantis](https://www.atlantis.run/blog/atlantis-releas
- Optionally, require a **review and approval** prior to running `apply`
➜ Also
- No more **copy-pasted code across workspaces/environments**. Atlantis supports using an `env/{env}.tfvars` file per workspace/environment so you can write your base configuration once
- Support **multiple versions of Terraform** with a simple project config file
## Atlantis Works With
* GitHub (public, private or enterprise) and GitLab (public, private or enterprise)
* Any Terraform version (see [Terraform Versions](#terraform-version))
* Can be run with a [single binary](https://github.com/runatlantis/atlantis/releases) or with our [Docker image](https://hub.docker.com/r/runatlantis/atlantis/)
* Any repository structure
## Getting Started
Download from [https://github.com/runatlantis/atlantis/releases](https://github.com/runatlantis/atlantis/releases)
@@ -71,15 +71,42 @@ If you're ready to permanently set up Atlantis see [Production-Ready Deployment]
## Pull/Merge Request Commands
Atlantis currently supports three commands that can be run via pull request comments (or merge request comments on GitLab):
![Help Command](./docs/pr-comment-help.png)
#### `atlantis help`
View help
#### `atlantis plan [workspace]`
Runs `terraform plan` for the changes in this pull request. If `[workspace]` is specified, will switch to that workspace, before running `plan`. Any additional arguments passed to `atlantis plan` will be passed on to `terraform plan`. For example if you'd like to run `terraform plan -target={target}` then you can comment `atlantis plan -target={target}`.
---
![Plan Command](./docs/pr-comment-plan.png)
#### `atlantis plan [options] -- [terraform plan flags]`
Runs `terraform plan` for the changes in this pull request.
#### `atlantis apply [workspace]`
Runs `terraform apply` for the plan generated by `atlantis plan`. If `[workspace]` is specified, will switch to that workspace.
Any additional arguments passed to `atlantis apply` will be passed on to `terraform apply`.
Options:
* `-d directory` Which directory to run plan in relative to root of repo. Use '.' for root. If not specified, will attempt to run plan for all Terraform projects we think were modified in this changeset.
* -w workspace` Switch to this [Terraform workspace](https://www.terraform.io/docs/state/workspaces.html) before planning. Defaults to 'default'. If not using Terraform workspaces you can ignore this.
* `--verbose` Append Atlantis log to comment.
Additional Terraform flags:
If you need to run `terraform plan` with additional arguments, like `-target=resource` or `-var 'foo-bar'`
you can append them to the end of the comment after `--`, ex.
```
atlantis plan -d dir -- -var 'foo=bar'
```
If you always need to append a certain flag, see [Project-Specific Customization](#project-specific-customization).
---
![Apply Command](./docs/pr-comment-apply.png)
#### `atlantis apply [options] -- [terraform apply flags]`
Runs `terraform plan` for the changes in this pull request.
Options:
* `-d directory` Apply the plan for this directory, relative to root of repo. Use '.' for root. If not specified, will run apply against all plans created for this workspace.
* -w workspace` Apply the plan for this [Terraform workspace](https://www.terraform.io/docs/state/workspaces.html). Defaults to 'default'. If not using Terraform workspaces you can ignore this.
* `--verbose` Append Atlantis log to comment.
Additional Terraform flags:
Same as with `atlantis plan`.
## Project Structure
Atlantis supports several Terraform project structures:
@@ -131,23 +158,24 @@ or
│   └── staging.tfvars
└── main.tf
```
With the above project structure you can de-duplicate your Terraform code between workspaces/environments without requiring extensive use of modules. At Hootsuite we've found this project format to be very successful and use it in all of our 100+ Terraform repositories.
With the above project structure you can de-duplicate your Terraform code between workspaces/environments without requiring extensive use of modules. At Hootsuite we found this project format to be very successful and use it in all of our 100+ Terraform repositories.
## Workspaces/Environments
Terraform introduced [Workspaces](https://www.terraform.io/docs/state/workspaces.html) in 0.9. They allow for
> a single directory of Terraform configuration to be used to manage multiple distinct sets of infrastructure resources
If you're using a Terraform version >= 0.9.0, Atlantis supports workspaces through an additional argument to the `atlantis plan` and `atlantis apply` commands.
If you're using a Terraform version >= 0.9.0, Atlantis supports workspaces through the `-w` flag.
For example,
```
atlantis plan staging
atlantis plan -w staging
```
If a workspace is specified, Atlantis will use `terraform workspace select {workspace}` prior to running `terraform plan` or `terraform apply`.
If you're using the `env/{env}.tfvars` [project structure](#project-structure) we will also append `-tfvars=env/{env}.tfvars` to `plan` and `apply`.
If no workspace is specified, terraform will use the `default` workspace by default.
If no workspace is specified, we'll use the `default` workspace by default.
This replicates Terraform's default behaviour which also uses the `default` workspace.
## Terraform Versions
By default, Atlantis will use the `terraform` executable that is in its path. To use a specific version of Terraform just install that version on the server that Atlantis is running on.
@@ -209,13 +237,13 @@ extra_arguments:
```
When running the `pre_plan`, `post_plan`, `pre_apply`, and `post_apply` commands the following environment variables are available
- `WORKSPACE`: if a workspace argument is supplied to `atlantis plan` or `atlantis apply`, ex `atlantis plan staging`, this will
- `WORKSPACE`: if a workspace argument is supplied to `atlantis plan` or `atlantis apply`, ex `atlantis plan -w staging`, this will
be the value of that argument. Else it will be `default`
- `ATLANTIS_TERRAFORM_VERSION`: local version of `terraform` or the version from `terraform_version` if specified, ex. `0.10.0`
- `DIR`: absolute path to the root of the project on disk
## Locking
When `plan` is run, the [project](#project) and [workspace](#workspaceenvironment) are **Locked** until an `apply` succeeds **and** the pull request/merge request is merged.
When `plan` is run, the [project](#project) and [workspace](#workspaceenvironment) (**but not the whole repo**) are **Locked** until an `apply` succeeds **and** the pull request/merge request is merged.
This protects against concurrent modifications to the same set of infrastructure and prevents
users from seeing a `plan` that will be invalid if another pull request is merged.
@@ -463,7 +491,7 @@ A Terraform workspace. See [terraform docs](https://www.terraform.io/docs/state/
## FAQ
**Q: Does Atlantis affect Terraform [remote state](https://www.terraform.io/docs/state/remote.html)?**
A: No. Atlantis does not interfere with Terraform remote state in anyway. Under the hood, Atlantis is simply executing `terraform plan` and `terraform apply`.
A: No. Atlantis does not interfere with Terraform remote state in any way. Under the hood, Atlantis is simply executing `terraform plan` and `terraform apply`.
**Q: How does Atlantis locking interact with Terraform [locking](https://www.terraform.io/docs/state/locking.html)?**

BIN
docs/pr-comment-apply.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/pr-comment-help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/pr-comment-plan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -46,22 +46,39 @@ func (a *ApplyExecutor) Execute(ctx *CommandContext) CommandResponse {
// Plans are stored at project roots by their workspace names. We just
// need to find them.
var plans []models.Plan
err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error {
// If they didn't specify a directory, we apply all plans we can find for
// this workspace.
if ctx.Command.Dir == "" {
err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Check if the plan is for the right workspace,
if !info.IsDir() && info.Name() == ctx.Command.Workspace+".tfplan" {
rel, _ := filepath.Rel(repoDir, filepath.Dir(path))
plans = append(plans, models.Plan{
Project: models.NewProject(ctx.BaseRepo.FullName, rel),
LocalPath: path,
})
}
return nil
})
if err != nil {
return err
return CommandResponse{Error: errors.Wrap(err, "finding plans")}
}
// Check if the plan is for the right workspace,
if !info.IsDir() && info.Name() == ctx.Command.Workspace+".tfplan" {
rel, _ := filepath.Rel(repoDir, filepath.Dir(path))
plans = append(plans, models.Plan{
Project: models.NewProject(ctx.BaseRepo.FullName, rel),
LocalPath: path,
})
} else {
// If they did specify a dir, we apply just the plan in that directory
// for this workspace.
path := filepath.Join(repoDir, ctx.Command.Dir, ctx.Command.Workspace+".tfplan")
stat, err := os.Stat(path)
if err != nil || stat.IsDir() {
return CommandResponse{Error: errors.Wrapf(err, "finding plan for dir %q and workspace %q", ctx.Command.Dir, ctx.Command.Workspace)}
}
return nil
})
if err != nil {
return CommandResponse{Error: errors.Wrap(err, "finding plans")}
rel, _ := filepath.Rel(repoDir, filepath.Dir(path))
plans = append(plans, models.Plan{
Project: models.NewProject(ctx.BaseRepo.FullName, filepath.Dir(rel)),
LocalPath: path,
})
}
if len(plans) == 0 {
return CommandResponse{Failure: "No plans found for that workspace."}

View File

@@ -3,12 +3,14 @@ package events
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/google/go-github/github"
"github.com/lkysow/go-gitlab"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/spf13/pflag"
)
const gitlabPullOpened = "opened"
@@ -20,6 +22,10 @@ type Command struct {
Workspace string
Verbose bool
Flags []string
// Dir is the path relative to the repo root to run the command in.
// If empty string then it wasn't specified. "." is the root of the repo.
// Dir will never end in "/".
Dir string
}
type EventParsing interface {
@@ -60,10 +66,6 @@ func (e *EventParser) DetermineCommand(comment string, vcsHost vcs.Host) (*Comma
return nil, err
}
workspace := "default"
verbose := false
var flags []string
vcsUser := e.GithubUser
if vcsHost == vcs.Gitlab {
vcsUser = e.GitlabUser
@@ -71,41 +73,75 @@ func (e *EventParser) DetermineCommand(comment string, vcsHost vcs.Host) (*Comma
if !e.stringInSlice(args[0], []string{"run", "atlantis", "@" + vcsUser}) {
return nil, err
}
if !e.stringInSlice(args[1], []string{"plan", "apply", "help"}) {
if !e.stringInSlice(args[1], []string{"plan", "apply", "help", "-help", "--help"}) {
return nil, err
}
if args[1] == "help" {
command := args[1]
if command == "help" || command == "-help" || command == "--help" {
return &Command{Name: Help}, nil
}
command := args[1]
if len(args) > 2 {
flags = args[2:]
var workspace string
var dir string
var verbose bool
var extraArgs []string
var flagSet *pflag.FlagSet
var name CommandName
// if the third arg doesn't start with '-' then we assume it's a
// workspace, not a flag
if !strings.HasPrefix(args[2], "-") {
workspace = args[2]
flags = args[3:]
}
// check for --verbose specially and then remove any additional
// occurrences
if e.stringInSlice("--verbose", flags) {
verbose = true
flags = e.removeOccurrences("--verbose", flags)
}
// Set up the flag parsing depending on the command.
const defaultWorkspace = "default"
if command == "plan" {
name = Plan
flagSet = pflag.NewFlagSet("plan", pflag.ContinueOnError)
flagSet.StringVarP(&workspace, "workspace", "w", defaultWorkspace, fmt.Sprintf("Switch to this Terraform workspace before planning. Defaults to '%s'", defaultWorkspace))
flagSet.StringVarP(&dir, "dir", "d", "", "Which directory to run plan in relative to root of repo. Use '.' for root. If not specified, will attempt to run plan for all Terraform projects we think were modified in this changeset.")
flagSet.BoolVarP(&verbose, "verbose", "", false, "Append Atlantis log to comment.")
} else if command == "apply" {
name = Apply
flagSet = pflag.NewFlagSet("apply", pflag.ContinueOnError)
flagSet.StringVarP(&workspace, "workspace", "w", defaultWorkspace, fmt.Sprintf("Apply the plan for this Terraform workspace. Defaults to '%s'", defaultWorkspace))
flagSet.StringVarP(&dir, "dir", "d", "", "Apply the plan for this directory, relative to root of repo. Use '.' for root. If not specified, will run apply against all plans created for this workspace.")
flagSet.BoolVarP(&verbose, "verbose", "", false, "Append Atlantis log to comment.")
} else {
return nil, fmt.Errorf("unknown command %q this is a bug", command)
}
c := &Command{Verbose: verbose, Workspace: workspace, Flags: flags}
switch command {
case "plan":
c.Name = Plan
case "apply":
c.Name = Apply
default:
return nil, fmt.Errorf("something went wrong parsing the command, the command we parsed %q was not apply or plan", command)
// Now parse the flags.
if err := flagSet.Parse(args[2:]); err != nil {
return nil, err
}
// We only use the extra args after the --. For example given a comment:
// "atlantis plan -bad-option -- -target=hi"
// we only append "-target=hi" to the eventual command.
// todo: keep track of the args we're discarding and include that with
// comment as a warning.
if flagSet.ArgsLenAtDash() != -1 {
extraArgs = flagSet.Args()[flagSet.ArgsLenAtDash():]
}
// If dir is specified, must ensure it's a valid path.
if dir != "" {
validatedDir := filepath.Clean(dir)
// Join with . so the path is relative. This helps us if they use '/',
// and is safe to do if their path is relative since it's a no-op.
validatedDir = filepath.Join(".", validatedDir)
// Need to clean again to resolve relative validatedDirs.
validatedDir = filepath.Clean(validatedDir)
// Detect relative dirs since they're not allowed.
if strings.HasPrefix(validatedDir, "..") {
return nil, fmt.Errorf("relative path %q not allowed", dir)
}
dir = validatedDir
}
// Because we use the workspace name as a file, need to make sure it's
// not doing something weird like being a relative dir.
if strings.Contains(workspace, "..") {
return nil, errors.New("workspace can't contain '..'")
}
c := &Command{Name: name, Verbose: verbose, Workspace: workspace, Dir: dir, Flags: extraArgs}
return c, nil
}
@@ -308,14 +344,3 @@ func (e *EventParser) stringInSlice(a string, list []string) bool {
}
return false
}
// nolint: unparam
func (e *EventParser) removeOccurrences(a string, list []string) []string {
var out []string
for _, b := range list {
if b != a {
out = append(out, b)
}
}
return out
}

View File

@@ -1,12 +1,11 @@
package events_test
import (
"testing"
"errors"
"strings"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/google/go-github/github"
"github.com/lkysow/go-gitlab"
@@ -26,7 +25,7 @@ var parser = events.EventParser{
}
func TestDetermineCommandInvalid(t *testing.T) {
t.Log("given a comment that does not match the regex should return an error")
t.Log("given an invalid comment, should return an error")
comments := []string{
// just the executable, no command
"run",
@@ -37,6 +36,15 @@ func TestDetermineCommandInvalid(t *testing.T) {
"atlantis slkjd",
"@github-user slkjd",
"atlantis plans",
// relative dirs
"atlantis plan -d ..",
"atlantis plan -d ../",
"atlantis plan -d a/../../",
// using .. in workspace
"atlantis plan -w a..",
"atlantis plan -w ../",
"atlantis plan -w ..",
"atlantis plan -w a/../b",
// misc
"related comment mentioning atlantis",
}
@@ -46,79 +54,215 @@ func TestDetermineCommandInvalid(t *testing.T) {
}
}
func TestDetermineCommandHelp(t *testing.T) {
func TestDetermineCommand_ExecutableNames(t *testing.T) {
t.Log("should be allowed to use different executable names in the comments")
parsed, err := parser.DetermineCommand("atlantis plan", vcs.Github)
Ok(t, err)
Equals(t, events.Plan, parsed.Name)
parsed, err = parser.DetermineCommand("run plan", vcs.Github)
Ok(t, err)
Equals(t, events.Plan, parsed.Name)
parsed, err = parser.DetermineCommand("@github-user plan", vcs.Github)
Ok(t, err)
Equals(t, events.Plan, parsed.Name)
parsed, err = parser.DetermineCommand("@gitlab-user plan", vcs.Gitlab)
Ok(t, err)
Equals(t, events.Plan, parsed.Name)
}
func TestDetermineCommand_Help(t *testing.T) {
t.Log("given a help comment, should match")
comments := []string{
"run help",
"atlantis help",
"@github-user help",
"atlantis help --verbose",
helpArgs := []string{
"help",
"-help",
"--help",
"help -verbose",
"help --hi",
"help somethingelse",
}
for _, c := range comments {
command, e := parser.DetermineCommand(c, vcs.Github)
Ok(t, e)
Equals(t, events.Help, command.Name)
for _, arg := range helpArgs {
comment := fmt.Sprintf("atlantis %s", arg)
command, err := parser.DetermineCommand(comment, vcs.Github)
Assert(t, err == nil, "did not parse comment %q as help command, got err: %s", comment, err)
Assert(t, command.Name == events.Help, "did not parse comment %q as help command", comment)
}
}
// nolint: gocyclo
func TestDetermineCommandPermutations(t *testing.T) {
execNames := []string{"run", "atlantis", "@github-user", "@gitlab-user"}
commandNames := []events.CommandName{events.Plan, events.Apply}
workspaces := []string{"", "default", "workspace", "workspace-dash", "workspace_underscore", "camelWorkspace"}
flagCases := [][]string{
{},
{"--verbose"},
{"-key=value"},
{"-key", "value"},
{"-key1=value1", "-key2=value2"},
{"-key1=value1", "-key2", "value2"},
{"-key1", "value1", "-key2=value2"},
{"--verbose", "key2=value2"},
{"-key1=value1", "--verbose"},
func TestDetermineCommand_Parsing(t *testing.T) {
cases := []struct {
flags string
expWorkspace string
expDir string
expVerbose bool
expExtraArgs string
}{
// Test defaults.
{
"",
"default",
"",
false,
"",
},
// Test each flag individually.
{
"-w workspace",
"workspace",
"",
false,
"",
},
{
"-d dir",
"default",
"dir",
false,
"",
},
{
"--verbose",
"default",
"",
true,
"",
},
// Test all of them with different permutations.
{
"-w workspace -d dir --verbose",
"workspace",
"dir",
true,
"",
},
{
"-d dir -w workspace --verbose",
"workspace",
"dir",
true,
"",
},
{
"--verbose -w workspace -d dir",
"workspace",
"dir",
true,
"",
},
// Test that flags after -- are ignored
{
"-w workspace -d dir -- --verbose",
"workspace",
"dir",
false,
"--verbose",
},
{
"-w workspace -- -d dir --verbose",
"workspace",
"",
false,
"-d dir --verbose",
},
// Test missing arguments.
{
"-w -d dir --verbose",
"-d",
"",
true,
"",
},
// Test the extra args parsing.
{
"--",
"default",
"",
false,
"",
},
{
"abc --",
"default",
"",
false,
"",
},
{
"-w workspace -d dir --verbose -- arg one -two --three &&",
"workspace",
"dir",
true,
"arg one -two --three &&",
},
// Test whitespace.
{
"\t-w\tworkspace\t-d\tdir\t--verbose\t--\targ\tone\t-two\t--three\t&&",
"workspace",
"dir",
true,
"arg one -two --three &&",
},
{
" -w workspace -d dir --verbose -- arg one -two --three &&",
"workspace",
"dir",
true,
"arg one -two --three &&",
},
// Test that the dir string is normalized.
{
"-d /",
"default",
".",
false,
"",
},
{
"-d /adir",
"default",
"adir",
false,
"",
},
{
"-d .",
"default",
".",
false,
"",
},
{
"-d ./",
"default",
".",
false,
"",
},
{
"-d ./adir",
"default",
"adir",
false,
"",
},
}
// test all permutations
for _, exec := range execNames {
for _, name := range commandNames {
for _, workspace := range workspaces {
for _, flags := range flagCases {
// If github comments end in a newline they get \r\n appended.
// Ensure that we parse commands properly either way.
for _, lineEnding := range []string{"", "\r\n"} {
comment := strings.Join(append([]string{exec, name.String(), workspace}, flags...), " ") + lineEnding
t.Log("testing comment: " + comment)
// In order to test gitlab without fully refactoring this test
// we're just detecting if we're using the gitlab user as the
// exec name.
vcsHost := vcs.Github
if exec == "@gitlab-user" {
vcsHost = vcs.Gitlab
}
c, err := parser.DetermineCommand(comment, vcsHost)
Ok(t, err)
Equals(t, name, c.Name)
if workspace == "" {
Equals(t, "default", c.Workspace)
} else {
Equals(t, workspace, c.Workspace)
}
Equals(t, containsVerbose(flags), c.Verbose)
// ensure --verbose never shows up in flags
for _, f := range c.Flags {
Assert(t, f != "--verbose", "Should not pass on the --verbose flag: %v", flags)
}
// check all flags are present
for _, f := range flags {
if f != "--verbose" {
Contains(t, f, c.Flags)
}
}
}
}
for _, test := range cases {
for _, cmdName := range []string{"plan", "apply"} {
comment := fmt.Sprintf("atlantis %s %s", cmdName, test.flags)
t.Logf("testing comment: %s", comment)
cmd, err := parser.DetermineCommand(comment, vcs.Github)
Assert(t, err == nil, "unexpected err parsing %q: %s", comment, err)
Equals(t, test.expDir, cmd.Dir)
Equals(t, test.expWorkspace, cmd.Workspace)
Equals(t, test.expVerbose, cmd.Verbose)
Equals(t, test.expExtraArgs, strings.Join(cmd.Flags, " "))
if cmdName == "plan" {
Assert(t, cmd.Name == events.Plan, "did not parse comment %q as plan command", comment)
}
if cmdName == "apply" {
Assert(t, cmd.Name == events.Apply, "did not parse comment %q as apply command", comment)
}
}
}
@@ -338,15 +482,6 @@ func TestParseGitlabMergeCommentEvent(t *testing.T) {
}, user)
}
func containsVerbose(list []string) bool {
for _, b := range list {
if b == "--verbose" {
return true
}
}
return false
}
var mergeEventJSON = `{
"object_kind": "merge_request",
"user": {

View File

@@ -52,21 +52,29 @@ func (p *PlanExecutor) SetLockURL(f func(id string) (url string)) {
// Execute executes terraform plan for the ctx.
func (p *PlanExecutor) Execute(ctx *CommandContext) CommandResponse {
// Figure out what projects have been modified so we know where to run plan.
modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull, ctx.VCSHost)
if err != nil {
return CommandResponse{Error: errors.Wrap(err, "getting modified files")}
}
cloneDir, err := p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.Command.Workspace)
if err != nil {
return CommandResponse{Error: err}
}
ctx.Log.Info("found %d files modified in this pull request", len(modifiedFiles))
projects := p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, cloneDir)
if len(projects) == 0 {
return CommandResponse{Failure: "No Terraform files were modified."}
var projects []models.Project
if ctx.Command.Dir == "" {
// If they didn't specify a directory to plan in, figure out what
// projects have been modified so we know where to run plan.
modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull, ctx.VCSHost)
if err != nil {
return CommandResponse{Error: errors.Wrap(err, "getting modified files")}
}
ctx.Log.Info("found %d files modified in this pull request", len(modifiedFiles))
projects = p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, cloneDir)
if len(projects) == 0 {
return CommandResponse{Failure: "No Terraform files were modified."}
}
} else {
projects = []models.Project{{
Path: ctx.Command.Dir,
RepoFullName: ctx.BaseRepo.FullName,
}}
}
var results []ProjectResult

View File

@@ -4,6 +4,7 @@ import (
"errors"
"testing"
"github.com/mohae/deepcopy"
. "github.com/petergtz/pegomock"
"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/locking"
@@ -22,6 +23,7 @@ var planCtx = events.CommandContext{
Command: &events.Command{
Name: events.Plan,
Workspace: "workspace",
Dir: "",
},
Log: logging.NewNoopLogger(),
BaseRepo: models.Repo{},
@@ -63,6 +65,38 @@ func TestExecute_CloneErr(t *testing.T) {
Equals(t, "err", r.Error.Error())
}
func TestExecute_DirectoryAndWorkspaceSet(t *testing.T) {
t.Log("Test that we run plan in the right directory and workspace if they're set")
p, runner, _ := setupPlanExecutorTest(t)
ctx := deepcopy.Copy(planCtx).(events.CommandContext)
ctx.Log = logging.NewNoopLogger()
ctx.Command.Dir = "dir1/dir2"
ctx.Command.Workspace = "workspace-flag"
When(p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, "workspace-flag")).
ThenReturn("/tmp/clone-repo", nil)
When(p.ProjectPreExecute.Execute(&ctx, "/tmp/clone-repo", models.Project{RepoFullName: "", Path: "dir1/dir2"})).
ThenReturn(events.PreExecuteResult{
LockResponse: locking.TryLockResponse{
LockKey: "key",
},
})
r := p.Execute(&ctx)
runner.VerifyWasCalledOnce().RunCommandWithVersion(
ctx.Log,
"/tmp/clone-repo/dir1/dir2",
[]string{"plan", "-refresh", "-no-color", "-out", "/tmp/clone-repo/dir1/dir2/workspace-flag.tfplan", "-var", "atlantis_user=anubhavmishra"},
nil,
"workspace-flag",
)
Assert(t, len(r.ProjectResults) == 1, "exp one project result")
result := r.ProjectResults[0]
Assert(t, result.PlanSuccess != nil, "exp plan success to not be nil")
Equals(t, "", result.PlanSuccess.TerraformOutput)
Equals(t, "lockurl-key", result.PlanSuccess.LockURL)
}
func TestExecute_Success(t *testing.T) {
t.Log("If there are no errors, the plan should be returned")
p, runner, _ := setupPlanExecutorTest(t)

View File

@@ -51,14 +51,28 @@ func (p *DefaultProjectPreExecutor) Execute(ctx *CommandContext, repoDir string,
lockAttempt.CurrLock.Pull.Num)}}
}
ctx.Log.Info("acquired lock with id %q", lockAttempt.LockKey)
config, tfVersion, err := p.executeWithLock(ctx, repoDir, project)
if err != nil {
p.Locker.Unlock(lockAttempt.LockKey) // nolint: errcheck
return PreExecuteResult{ProjectResult: ProjectResult{Error: err}}
}
return PreExecuteResult{ProjectConfig: config, TerraformVersion: tfVersion, LockResponse: lockAttempt}
}
// executeWithLock executes the pre plan/apply tasks after the lock has been
// acquired. This helper func makes revoking the lock on error easier.
// Returns the project config, terraform version, or an error.
func (p *DefaultProjectPreExecutor) executeWithLock(ctx *CommandContext, repoDir string, project models.Project) (ProjectConfig, *version.Version, error) {
workspace := ctx.Command.Workspace
// Check if config file is found, if not we continue the run.
var config ProjectConfig
absolutePath := filepath.Join(repoDir, project.Path)
if p.ConfigReader.Exists(absolutePath) {
var err error
config, err = p.ConfigReader.Read(absolutePath)
if err != nil {
return PreExecuteResult{ProjectResult: ProjectResult{Error: err}}
return config, nil, err
}
ctx.Log.Info("parsed atlantis config file in %q", absolutePath)
}
@@ -74,25 +88,25 @@ func (p *DefaultProjectPreExecutor) Execute(ctx *CommandContext, repoDir string,
if len(config.PreInit) > 0 {
_, err := p.Run.Execute(ctx.Log, config.PreInit, absolutePath, workspace, terraformVersion, "pre_init")
if err != nil {
return PreExecuteResult{ProjectResult: ProjectResult{Error: errors.Wrapf(err, "running %s commands", "pre_init")}}
return config, nil, errors.Wrapf(err, "running %s commands", "pre_init")
}
}
_, err := p.Terraform.Init(ctx.Log, absolutePath, workspace, config.GetExtraArguments("init"), terraformVersion)
if err != nil {
return PreExecuteResult{ProjectResult: ProjectResult{Error: err}}
return config, nil, err
}
} else {
ctx.Log.Info("determined that we are running terraform with version < 0.9.0. Running version %s", terraformVersion)
if len(config.PreGet) > 0 {
_, err := p.Run.Execute(ctx.Log, config.PreGet, absolutePath, workspace, terraformVersion, "pre_get")
if err != nil {
return PreExecuteResult{ProjectResult: ProjectResult{Error: errors.Wrapf(err, "running %s commands", "pre_get")}}
return config, nil, errors.Wrapf(err, "running %s commands", "pre_get")
}
}
terraformGetCmd := append([]string{"get", "-no-color"}, config.GetExtraArguments("get")...)
_, err := p.Terraform.RunCommandWithVersion(ctx.Log, absolutePath, terraformGetCmd, terraformVersion, workspace)
if err != nil {
return PreExecuteResult{ProjectResult: ProjectResult{Error: err}}
return config, nil, err
}
}
@@ -106,8 +120,8 @@ func (p *DefaultProjectPreExecutor) Execute(ctx *CommandContext, repoDir string,
if len(commands) > 0 {
_, err := p.Run.Execute(ctx.Log, commands, absolutePath, workspace, terraformVersion, stage)
if err != nil {
return PreExecuteResult{ProjectResult: ProjectResult{Error: errors.Wrapf(err, "running %s commands", stage)}}
return config, nil, errors.Wrapf(err, "running %s commands", stage)
}
}
return PreExecuteResult{ProjectConfig: config, TerraformVersion: terraformVersion, LockResponse: lockAttempt}
return config, terraformVersion, nil
}