From 2225da9c61cd0893a443b3cea9b758cbc25e83df Mon Sep 17 00:00:00 2001 From: Luke Kysow <1034429+lkysow@users.noreply.github.com> Date: Wed, 6 Feb 2019 15:49:08 -0600 Subject: [PATCH 1/2] Switch to golangci-lint from gometalinter. Hoping that this is more stable. --- .circleci/config.yml | 2 +- .golangci.yml | 21 +++++++++++++++++++++ .gometalinter.json | 44 -------------------------------------------- Makefile | 19 ++++++------------- 4 files changed, 28 insertions(+), 58 deletions(-) create mode 100644 .golangci.yml delete mode 100644 .gometalinter.json diff --git a/.circleci/config.yml b/.circleci/config.yml index e38f39a499..cc10caf892 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: - checkout - run: make test-coverage - run: make check-fmt - - run: make check-gometalint + - run: make check-lint - run: name: post coverage to codecov.io command: bash <(curl -s https://codecov.io/bash) diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000000..0630766f93 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,21 @@ +linters: + enable: + - deadcode + - errcheck + - gochecknoinits +# We don't use goconst because it gives false positives in the tests. +# - goconst + - gofmt + - golint + - gosec + - gosimple + - ineffassign + - interfacer + - staticcheck + - structcheck + - typecheck + - unconvert + - unused + - varcheck + - vet + - vetshadow diff --git a/.gometalinter.json b/.gometalinter.json deleted file mode 100644 index e1dca4de96..0000000000 --- a/.gometalinter.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "Disable": [ - "gotype", - "gotypex", - "maligned", - "gocyclo", - "golint" - ], - "Enable": [ - "gosec", - "errcheck", - "deadcode", - "gochecknoinits", - "goconst", - "gofmt", - "goimports", - "golint", - "ineffassign", - "interfacer", - "maligned", - "staticcheck", - "structcheck", - "unconvert", - "varcheck", - "vet", - "vetshadow" - ], - "Deadline": "300s", - "Vendor": true, - "LineLength": 120, - "Skip": [ - "server/static", - "mocks" - ], - "WarnUnmatchedDirective": true, - "Linters": { - "gosec": "gosec -exclude=G104 -fmt=csv:^(?P.*?\\.go),(?P\\d+),(?P[^,]+,[^,]+,[^,]+)", - "errcheck": { - "Command": "errcheck -abspath -ignore 'fmt:.*'", - "Pattern": "PATH:LINE:COL:MESSAGE", - "PartitionStrategy": "packages" - } - } -} diff --git a/Makefile b/Makefile index 5884cfe355..d9fcd83ae8 100644 --- a/Makefile +++ b/Makefile @@ -60,19 +60,12 @@ release: ## Create packages for a release fmt: ## Run goimports (which also formats) goimports -w $$(find . -type f -name '*.go' ! -path "./vendor/*" ! -path "./server/static/bindata_assetfs.go" ! -path "**/mocks/*") -gometalint: ## Run every linter ever - # gotype and gotypex are disabled because they don't pass on CI and https://github.com/alecthomas/gometalinter/issues/206 - # maligned is disabled because I'd rather have alphabetical struct fields than save a few bytes - # gocyclo is temporarily disabled because we don't pass it right now - # golint is temporarily disabled because we need to add comments everywhere first - # CGO_ENABLED=0 is attempted workaround for https://github.com/alecthomas/gometalinter/issues/149 - CGO_ENABLED=0 gometalinter --config=.gometalinter.json ./... - -gometalint-install: ## Install gometalint - go get -u github.com/alecthomas/gometalinter - gometalinter --install - -check-gometalint: gometalint-install gometalint +lint: ## Run linter + golangci-lint run + +check-lint: + curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b ./bin v1.13.2 + ./bin/golangci-lint run check-fmt: ## Fail if not formatted go get golang.org/x/tools/cmd/goimports From 74e9bbb82b8e0754be60129ae2508dbe129389fe Mon Sep 17 00:00:00 2001 From: Luke Kysow <1034429+lkysow@users.noreply.github.com> Date: Wed, 6 Feb 2019 15:46:10 -0600 Subject: [PATCH 2/2] Add automerge feature. Automerging merges pull requests automatically if all plans have been successfully applied. * Save status of PR's to BoltDB so after each apply, we can check if there are pending plans. * Add new feature where we delete successful plans *unless* all plans have succeeded *if* automerge is enabled. This was requested by users because when automerge is enabled, they want to enforce that a pull request's changes have been fully applied. They asked that plans not be allowed to be applied "piecemeal" and instead, all plans must be generated successfully prior to allowing any plans to be applied. --- .circleci/config.yml | 7 +- cmd/server.go | 6 + cmd/server_test.go | 14 + runatlantis.io/.vuepress/config.js | 1 + .../docs/atlantis-yaml-reference.md | 13 +- runatlantis.io/docs/automerging.md | 44 ++ runatlantis.io/docs/images/automerge.png | Bin 0 -> 85644 bytes server/events/command_result.go | 22 +- server/events/command_result_test.go | 108 +++++ server/events/command_runner.go | 120 ++++- server/events/command_runner_test.go | 48 +- server/events/commit_status_updater_test.go | 10 +- server/events/db/boltdb.go | 407 +++++++++++++++++ .../{locking/boltdb => db}/boltdb_test.go | 334 +++++++++++++- server/events/locking/boltdb/boltdb.go | 223 --------- server/events/markdown_renderer.go | 59 ++- server/events/markdown_renderer_test.go | 163 +++++-- .../mocks/matchers/events_projectresult.go | 14 +- .../matchers/slice_of_events_pendingplan.go | 20 + .../events/mocks/mock_pending_plan_finder.go | 144 ++++++ .../mocks/mock_project_command_runner.go | 21 +- server/events/models/models.go | 86 ++++ server/events/models/models_test.go | 39 ++ server/events/pending_plan_finder.go | 41 +- server/events/pending_plan_finder_test.go | 50 ++- server/events/project_command_builder.go | 2 +- server/events/project_command_builder_test.go | 4 +- server/events/project_command_runner.go | 44 +- server/events/project_command_runner_test.go | 15 +- server/events/project_result.go | 40 -- server/events/pull_closed_executor.go | 10 + server/events/pull_closed_executor_test.go | 45 +- server/events/vcs/bitbucketcloud/client.go | 13 +- .../events/vcs/bitbucketcloud/client_test.go | 2 +- server/events/vcs/bitbucketserver/client.go | 17 +- .../vcs/bitbucketserver/request_validation.go | 1 + server/events/vcs/client.go | 1 + .../common/{comment_splitter.go => common.go} | 6 + ...omment_splitter_test.go => common_test.go} | 0 server/events/vcs/github_client.go | 18 + server/events/vcs/github_client_test.go | 85 +++- server/events/vcs/gitlab_client.go | 14 + server/events/vcs/gitlab_client_test.go | 74 +++ server/events/vcs/mocks/mock_proxy.go | 42 ++ .../events/vcs/not_configured_vcs_client.go | 3 + server/events/vcs/proxy.go | 5 + server/events/working_dir.go | 2 +- server/events/yaml/raw/config.go | 11 + server/events/yaml/raw/config_test.go | 55 ++- server/events/yaml/valid/valid.go | 1 + server/events_controller_e2e_test.go | 423 +++++++++++------- server/locks_controller.go | 5 + server/locks_controller_test.go | 11 + server/server.go | 14 +- .../test-repos/automerge/atlantis.yaml | 5 + .../test-repos/automerge/dir1/main.tf | 3 + .../test-repos/automerge/dir2/main.tf | 3 + .../automerge/exp-output-apply-dir1.txt | 9 + .../automerge/exp-output-apply-dir2.txt | 9 + .../automerge/exp-output-automerge.txt | 1 + .../automerge/exp-output-autoplan.txt | 48 ++ .../test-repos/automerge/exp-output-merge.txt | 4 + server/user_config.go | 1 + testdrive/utils.go | 2 +- 64 files changed, 2452 insertions(+), 590 deletions(-) create mode 100644 runatlantis.io/docs/automerging.md create mode 100644 runatlantis.io/docs/images/automerge.png create mode 100644 server/events/command_result_test.go create mode 100644 server/events/db/boltdb.go rename server/events/{locking/boltdb => db}/boltdb_test.go (54%) delete mode 100644 server/events/locking/boltdb/boltdb.go create mode 100644 server/events/mocks/matchers/slice_of_events_pendingplan.go create mode 100644 server/events/mocks/mock_pending_plan_finder.go delete mode 100644 server/events/project_result.go rename server/events/vcs/common/{comment_splitter.go => common.go} (74%) rename server/events/vcs/common/{comment_splitter_test.go => common_test.go} (100%) create mode 100644 server/testfixtures/test-repos/automerge/atlantis.yaml create mode 100644 server/testfixtures/test-repos/automerge/dir1/main.tf create mode 100644 server/testfixtures/test-repos/automerge/dir2/main.tf create mode 100644 server/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt create mode 100644 server/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt create mode 100644 server/testfixtures/test-repos/automerge/exp-output-automerge.txt create mode 100644 server/testfixtures/test-repos/automerge/exp-output-autoplan.txt create mode 100644 server/testfixtures/test-repos/automerge/exp-output-merge.txt diff --git a/.circleci/config.yml b/.circleci/config.yml index cc10caf892..e44e967147 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,9 +42,10 @@ jobs: # We use dockerize -wait here to wait until the server is up. - run: | dockerize -wait tcp://localhost:8080 -- \ - muffet -e 'https://github\\.com/runatlantis/atlantis/edit/master/.*' \ - -e 'https://github.com/helm/charts/tree/master/stable/atlantis#customization' \ - http://localhost:8080/ + muffet \ + -e 'https://github\.com/runatlantis/atlantis/edit/master/.*' \ + -e 'https://github.com/helm/charts/tree/master/stable/atlantis#customization' \ + http://localhost:8080/ # Build and push 'latest' Docker tag. docker_master: diff --git a/cmd/server.go b/cmd/server.go index 09cbd3820f..fe74365b50 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -39,6 +39,7 @@ const ( AllowForkPRsFlag = "allow-fork-prs" AllowRepoConfigFlag = "allow-repo-config" AtlantisURLFlag = "atlantis-url" + AutomergeFlag = "automerge" BitbucketBaseURLFlag = "bitbucket-base-url" BitbucketTokenFlag = "bitbucket-token" BitbucketUserFlag = "bitbucket-user" @@ -205,6 +206,11 @@ var boolFlags = []boolFlag{ " on the Atlantis server.", defaultValue: false, }, + { + name: AutomergeFlag, + description: "Automatically merge pull requests when all plans are successfully applied.", + defaultValue: false, + }, { name: RequireApprovalFlag, description: "Require pull requests to be \"Approved\" before allowing the apply command to be run.", diff --git a/cmd/server_test.go b/cmd/server_test.go index 9b880c3a66..c9396bf373 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -333,6 +333,7 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, "http://"+hostname+":4141", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, false, passedConfig.Automerge) // Get our home dir since that's what gets defaulted to dataDir, err := homedir.Expand("~/.atlantis") @@ -438,6 +439,7 @@ func TestExecute_Flags(t *testing.T) { cmd.AtlantisURLFlag: "url", cmd.AllowForkPRsFlag: true, cmd.AllowRepoConfigFlag: true, + cmd.AutomergeFlag: true, cmd.BitbucketBaseURLFlag: "https://bitbucket-base-url.com", cmd.BitbucketTokenFlag: "bitbucket-token", cmd.BitbucketUserFlag: "bitbucket-user", @@ -468,6 +470,7 @@ func TestExecute_Flags(t *testing.T) { Equals(t, "url", passedConfig.AtlantisURL) Equals(t, true, passedConfig.AllowForkPRs) Equals(t, true, passedConfig.AllowRepoConfig) + Equals(t, true, passedConfig.Automerge) Equals(t, "https://bitbucket-base-url.com", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) @@ -499,6 +502,7 @@ func TestExecute_ConfigFile(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +automerge: true bitbucket-base-url: "https://mydomain.com" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" @@ -533,6 +537,7 @@ tfe-token: my-token Equals(t, "url", passedConfig.AtlantisURL) Equals(t, true, passedConfig.AllowForkPRs) Equals(t, true, passedConfig.AllowRepoConfig) + Equals(t, true, passedConfig.Automerge) Equals(t, "https://mydomain.com", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) @@ -564,6 +569,7 @@ func TestExecute_EnvironmentOverride(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +automerge: true bitbucket-base-url: "https://mydomain.com" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" @@ -594,6 +600,7 @@ tfe-token: my-token "ATLANTIS_URL": "override-url", "ALLOW_FORK_PRS": "false", "ALLOW_REPO_CONFIG": "false", + "AUTOMERGE": "false", "BITBUCKET_BASE_URL": "https://override-bitbucket-base-url", "BITBUCKET_TOKEN": "override-bitbucket-token", "BITBUCKET_USER": "override-bitbucket-user", @@ -628,6 +635,7 @@ tfe-token: my-token Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, false, passedConfig.Automerge) Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) @@ -659,6 +667,7 @@ func TestExecute_FlagConfigOverride(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +automerge: true bitbucket-base-url: "https://bitbucket-base-url" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" @@ -689,6 +698,7 @@ tfe-token: my-token cmd.AtlantisURLFlag: "override-url", cmd.AllowForkPRsFlag: false, cmd.AllowRepoConfigFlag: false, + cmd.AutomergeFlag: false, cmd.BitbucketBaseURLFlag: "https://override-bitbucket-base-url", cmd.BitbucketTokenFlag: "override-bitbucket-token", cmd.BitbucketUserFlag: "override-bitbucket-user", @@ -717,6 +727,7 @@ tfe-token: my-token Ok(t, err) Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) + Equals(t, false, passedConfig.Automerge) Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) @@ -750,6 +761,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { "ATLANTIS_URL": "url", "ALLOW_FORK_PRS": "true", "ALLOW_REPO_CONFIG": "true", + "AUTOMERGE": "true", "BITBUCKET_BASE_URL": "https://bitbucket-base-url", "BITBUCKET_TOKEN": "bitbucket-token", "BITBUCKET_USER": "bitbucket-user", @@ -788,6 +800,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { cmd.AtlantisURLFlag: "override-url", cmd.AllowForkPRsFlag: false, cmd.AllowRepoConfigFlag: false, + cmd.AutomergeFlag: false, cmd.BitbucketBaseURLFlag: "https://override-bitbucket-base-url", cmd.BitbucketTokenFlag: "override-bitbucket-token", cmd.BitbucketUserFlag: "override-bitbucket-user", @@ -818,6 +831,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, false, passedConfig.Automerge) Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) diff --git a/runatlantis.io/.vuepress/config.js b/runatlantis.io/.vuepress/config.js index ad7d2cbe3a..cc0a703bb8 100644 --- a/runatlantis.io/.vuepress/config.js +++ b/runatlantis.io/.vuepress/config.js @@ -81,6 +81,7 @@ module.exports = { ['how-atlantis-works', 'Overview'], 'locking', 'autoplanning', + 'automerging', 'checkout-strategy', 'security' ] diff --git a/runatlantis.io/docs/atlantis-yaml-reference.md b/runatlantis.io/docs/atlantis-yaml-reference.md index 936af62177..aeb333db71 100644 --- a/runatlantis.io/docs/atlantis-yaml-reference.md +++ b/runatlantis.io/docs/atlantis-yaml-reference.md @@ -16,6 +16,7 @@ to use `atlantis.yaml` files. ## Example Using All Keys ```yaml version: 2 +automerge: true projects: - name: my-project-name dir: . @@ -67,14 +68,16 @@ It should be noted that `atlantis apply` itself could be exploited if run on a m ### Top-Level Keys ```yaml version: +automerge: projects: workflows: ``` -| Key | Type | Default | Required | Description | -| --------- | ---------------------------------------------------------------- | ------- | -------- | ------------------------------------------- | -| version | int | none | yes | This key is required and must be set to `2` | -| projects | array[[Project](atlantis-yaml-reference.html#project)] | [] | no | Lists the projects in this repo | -| workflows | map[string -> [Workflow](atlantis-yaml-reference.html#workflow)] | {} | no | Custom workflows | +| Key | Type | Default | Required | Description | +| --------- | ---------------------------------------------------------------- | ------- | -------- | ----------------------------------------------------------- | +| version | int | none | yes | This key is required and must be set to `2` | +| automerge | bool | false | no | Automatically merge pull request when all plans are applied | +| projects | array[[Project](atlantis-yaml-reference.html#project)] | [] | no | Lists the projects in this repo | +| workflows | map[string -> [Workflow](atlantis-yaml-reference.html#workflow)] | {} | no | Custom workflows | ### Project ```yaml diff --git a/runatlantis.io/docs/automerging.md b/runatlantis.io/docs/automerging.md new file mode 100644 index 0000000000..aff40372f0 --- /dev/null +++ b/runatlantis.io/docs/automerging.md @@ -0,0 +1,44 @@ +# Automerging +Atlantis can be configured to automatically merge a pull request after all plans have +been successfully applied. + + +![Automerge](./images/automerge.png) + +## How To Enable +Automerging can be enabled either by: +1. Passing the `--automerge` flag to `atlantis server`. This will cause all + pull requests to be automerged and any repo config will be ignored. +1. Setting `automerge: true` in the repo's `atlantis.yaml` file: + ```yaml + version: 2 + automerge: true + projects: + - dir: . + ``` + :::tip NOTE + If a repo has an `atlantis.yaml` file, then each project in the repo needs + to be configured under the `projects` key. + ::: + +## All Plans Must Succeed +When automerge is enabled, **all plans** in a pull request **must succeed** before +**any** plans can be applied. + +For example, imagine this scenario: +1. I open a pull request that makes changes to two Terraform projects, in `dir1/` + and `dir2/`. +1. The plan for `dir2/` fails because my Terraform syntax is wrong. + +In this scenario, I can't run +``` +atlantis apply -d dir1 +``` +Even though that plan succeeded, because **all** plans must succeed for **any** plans +to be saved. + +Once I fix the issue in `dir2`, I can push a new commit which will trigger an +autoplan. Then I will be able to apply both plans. + +## Permissions +The Atlantis VCS user must have the ability to merge pull requests. diff --git a/runatlantis.io/docs/images/automerge.png b/runatlantis.io/docs/images/automerge.png new file mode 100644 index 0000000000000000000000000000000000000000..79274caf2b0b6c9817cbfd2f2bbbd03dcd30f613 GIT binary patch literal 85644 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 XYP`PL=vYMn{5??AR49{w`ttt)s`%+8 literal 0 HcmV?d00001 diff --git a/server/events/command_result.go b/server/events/command_result.go index 4724ab7c0e..655bf8a081 100644 --- a/server/events/command_result.go +++ b/server/events/command_result.go @@ -13,9 +13,29 @@ package events +import "github.com/runatlantis/atlantis/server/events/models" + // CommandResult is the result of running a Command. type CommandResult struct { Error error Failure string - ProjectResults []ProjectResult + ProjectResults []models.ProjectResult + // PlansDeleted is true if all plans created during this command were + // deleted. This happens if automerging is enabled and one project has an + // error since automerging requires all plans to succeed. + PlansDeleted bool +} + +// HasErrors returns true if there were any errors during the execution, +// even if it was only in one project. +func (c CommandResult) HasErrors() bool { + if c.Error != nil || c.Failure != "" { + return true + } + for _, r := range c.ProjectResults { + if !r.IsSuccessful() { + return true + } + } + return false } diff --git a/server/events/command_result_test.go b/server/events/command_result_test.go new file mode 100644 index 0000000000..bcdeae365f --- /dev/null +++ b/server/events/command_result_test.go @@ -0,0 +1,108 @@ +package events_test + +import ( + "errors" + "testing" + + "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/models" + . "github.com/runatlantis/atlantis/testing" +) + +func TestCommandResult_HasErrors(t *testing.T) { + cases := map[string]struct { + cr events.CommandResult + exp bool + }{ + "error": { + cr: events.CommandResult{ + Error: errors.New("err"), + }, + exp: true, + }, + "failure": { + cr: events.CommandResult{ + Failure: "failure", + }, + exp: true, + }, + "empty results list": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{}, + }, + exp: false, + }, + "successful plan": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + PlanSuccess: &models.PlanSuccess{}, + }, + }, + }, + exp: false, + }, + "successful apply": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + ApplySuccess: "success", + }, + }, + }, + exp: false, + }, + "single errored project": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + Error: errors.New("err"), + }, + }, + }, + exp: true, + }, + "single failed project": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + Failure: "failure", + }, + }, + }, + exp: true, + }, + "two successful projects": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + PlanSuccess: &models.PlanSuccess{}, + }, + { + ApplySuccess: "success", + }, + }, + }, + exp: false, + }, + "one successful, one failed project": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + PlanSuccess: &models.PlanSuccess{}, + }, + { + Failure: "failed", + }, + }, + }, + exp: true, + }, + } + + for descrip, c := range cases { + t.Run(descrip, func(t *testing.T) { + Equals(t, c.exp, c.cr.HasErrors()) + }) + } +} diff --git a/server/events/command_runner.go b/server/events/command_runner.go index e9d3c1d565..2e28507185 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -15,10 +15,10 @@ package events import ( "fmt" - "github.com/google/go-github/github" "github.com/lkysow/go-gitlab" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/db" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" @@ -69,6 +69,12 @@ type DefaultCommandRunner struct { AllowForkPRsFlag string ProjectCommandBuilder ProjectCommandBuilder ProjectCommandRunner ProjectCommandRunner + // GlobalAutomerge is true if we should automatically merge pull requests if all + // plans have been successfully applied. This is set via a CLI flag. + GlobalAutomerge bool + PendingPlanFinder PendingPlanFinder + WorkingDir WorkingDir + DB *db.BoltDB } // RunAutoplanCommand runs plan when a pull request is opened or updated. @@ -102,8 +108,17 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo return } - results := c.runProjectCmds(projectCmds, PlanCommand) - c.updatePull(ctx, AutoplanCommand{}, CommandResult{ProjectResults: results}) + result := c.runProjectCmds(projectCmds, PlanCommand) + if c.automergeEnabled(ctx, projectCmds) && result.HasErrors() { + ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed") + c.deletePlans(ctx) + result.PlansDeleted = true + } + c.updatePull(ctx, AutoplanCommand{}, result) + _, err = c.updateDB(ctx, ctx.Pull, result.ProjectResults) + if err != nil { + c.Logger.Err("writing results: %s", err) + } } // RunCommentCommand executes the command. @@ -152,7 +167,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead if !c.validateCtxAndComment(ctx) { return } - if err = c.CommitStatusUpdater.Update(ctx.BaseRepo, ctx.Pull, models.PendingCommitStatus, cmd.CommandName()); err != nil { + if err = c.CommitStatusUpdater.Update(baseRepo, pull, models.PendingCommitStatus, cmd.CommandName()); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } @@ -170,18 +185,62 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead c.updatePull(ctx, cmd, CommandResult{Error: err}) return } - results := c.runProjectCmds(projectCmds, cmd.Name) + + result := c.runProjectCmds(projectCmds, cmd.Name) + if cmd.Name == PlanCommand && c.automergeEnabled(ctx, projectCmds) && result.HasErrors() { + ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed") + c.deletePlans(ctx) + result.PlansDeleted = true + } c.updatePull( ctx, cmd, - CommandResult{ - ProjectResults: results}) + result) + + pullStatus, err := c.updateDB(ctx, pull, result.ProjectResults) + if err != nil { + c.Logger.Err("writing results: %s", err) + return + } + + if cmd.Name == ApplyCommand && c.automergeEnabled(ctx, projectCmds) { + c.automerge(ctx, pullStatus) + } +} + +func (c *DefaultCommandRunner) automerge(ctx *CommandContext, pullStatus *models.PullStatus) { + // We only automerge if all projects have been successfully applied. + for _, p := range pullStatus.Projects { + if p.Status != models.AppliedPlanStatus { + ctx.Log.Info("not automerging because project at dir %q, workspace %q has status %q", p.RepoRelDir, p.Workspace, p.Status.String()) + return + } + } + + // Comment that we're automerging the pull request. + if err := c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, automergeComment); err != nil { + ctx.Log.Err("failed to comment about automerge: %s", err) + // Commenting isn't required so continue. + } + + // Make the API call to perform the merge. + ctx.Log.Info("automerging pull request") + err := c.VCSClient.MergePull(ctx.Pull) + + if err != nil { + ctx.Log.Err("automerging failed: %s", err) + + failureComment := fmt.Sprintf("Automerging failed: %s", err) + if commentErr := c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, failureComment); commentErr != nil { + ctx.Log.Err("failed to comment about automerge failing: %s", err) + } + } } -func (c *DefaultCommandRunner) runProjectCmds(cmds []models.ProjectCommandContext, cmdName CommandName) []ProjectResult { - var results []ProjectResult +func (c *DefaultCommandRunner) runProjectCmds(cmds []models.ProjectCommandContext, cmdName CommandName) CommandResult { + var results []models.ProjectResult for _, pCmd := range cmds { - var res ProjectResult + var res models.ProjectResult switch cmdName { case PlanCommand: res = c.ProjectCommandRunner.Plan(pCmd) @@ -190,7 +249,7 @@ func (c *DefaultCommandRunner) runProjectCmds(cmds []models.ProjectCommandContex } results = append(results, res) } - return results + return CommandResult{ProjectResults: results} } func (c *DefaultCommandRunner) getGithubData(baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { @@ -276,3 +335,42 @@ func (c *DefaultCommandRunner) logPanics(baseRepo models.Repo, pullNum int, logg } } } + +// deletePlans deletes all plans generated in this ctx. +func (c *DefaultCommandRunner) deletePlans(ctx *CommandContext) { + pullDir, err := c.WorkingDir.GetPullDir(ctx.BaseRepo, ctx.Pull) + if err != nil { + ctx.Log.Err("getting pull dir: %s", err) + } + if err := c.PendingPlanFinder.DeletePlans(pullDir); err != nil { + ctx.Log.Err("deleting pending plans: %s", err) + } +} + +func (c *DefaultCommandRunner) updateDB(ctx *CommandContext, pull models.PullRequest, results []models.ProjectResult) (*models.PullStatus, error) { + // Filter out results that errored due to the directory not existing. We + // don't store these in the database because they would never be "applyable" + // and so the pull request would always have errors. + var filtered []models.ProjectResult + for _, r := range results { + if _, ok := r.Error.(DirNotExistErr); ok { + ctx.Log.Debug("ignoring error result from project at dir %q workspace %q because it is dir not exist error", r.RepoRelDir, r.Workspace) + continue + } + filtered = append(filtered, r) + } + ctx.Log.Debug("updating DB with pull results") + return c.DB.UpdatePullWithResults(pull, filtered) +} + +// automergeEnabled returns true if automerging is enabled in this context. +func (c *DefaultCommandRunner) automergeEnabled(ctx *CommandContext, projectCmds []models.ProjectCommandContext) bool { + // If the global automerge is set, we always automerge. + return c.GlobalAutomerge || + // Otherwise we check if this repo is configured for automerging. + (len(projectCmds) > 0 && projectCmds[0].GlobalConfig != nil && projectCmds[0].GlobalConfig.Automerge) +} + +// automergeComment is the comment that gets posted when Atlantis automatically +// merges the PR. +var automergeComment = `Automatically merging because all plans have been successfully applied.` diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 4467d8102a..f278e1454d 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -34,12 +34,15 @@ import ( ) var projectCommandBuilder *mocks.MockProjectCommandBuilder +var projectCommandRunner *mocks.MockProjectCommandRunner var eventParsing *mocks.MockEventParsing var ghStatus *mocks.MockCommitStatusUpdater var githubGetter *mocks.MockGithubPullGetter var gitlabGetter *mocks.MockGitlabMergeRequestGetter var ch events.DefaultCommandRunner var pullLogger *logging.SimpleLogger +var workingDir events.WorkingDir +var pendingPlanFinder *mocks.MockPendingPlanFinder func setup(t *testing.T) *vcsmocks.MockClientProxy { RegisterMockTestingT(t) @@ -51,7 +54,9 @@ func setup(t *testing.T) *vcsmocks.MockClientProxy { gitlabGetter = mocks.NewMockGitlabMergeRequestGetter() logger := logmocks.NewMockSimpleLogging() pullLogger = logging.NewSimpleLogger("runatlantis/atlantis#1", true, logging.Info) - projectCommandRunner := mocks.NewMockProjectCommandRunner() + projectCommandRunner = mocks.NewMockProjectCommandRunner() + workingDir = mocks.NewMockWorkingDir() + pendingPlanFinder = mocks.NewMockPendingPlanFinder() When(logger.GetLevel()).ThenReturn(logging.Info) When(logger.NewLogger("runatlantis/atlantis#1", true, logging.Info)). ThenReturn(pullLogger) @@ -67,6 +72,8 @@ func setup(t *testing.T) *vcsmocks.MockClientProxy { AllowForkPRsFlag: "allow-fork-prs-flag", ProjectCommandBuilder: projectCommandBuilder, ProjectCommandRunner: projectCommandRunner, + PendingPlanFinder: pendingPlanFinder, + WorkingDir: workingDir, } return vcsClient } @@ -155,3 +162,42 @@ func TestRunCommentCommand_ClosedPull(t *testing.T) { ch.RunCommentCommand(fixtures.GithubRepo, &fixtures.GithubRepo, nil, fixtures.User, fixtures.Pull.Num, nil) vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "Atlantis commands can't be run on closed pull requests") } + +// Test that if one plan fails and we are using automerge, that +// we delete the plans. +func TestRunAutoplanCommand_DeletePlans(t *testing.T) { + setup(t) + ch.GlobalAutomerge = true + defer func() { ch.GlobalAutomerge = false }() + + When(projectCommandBuilder.BuildAutoplanCommands(matchers.AnyPtrToEventsCommandContext())). + ThenReturn([]models.ProjectCommandContext{ + {}, + {}, + }, nil) + callCount := 0 + When(projectCommandRunner.Plan(matchers.AnyModelsProjectCommandContext())).Then(func(_ []Param) ReturnValues { + if callCount == 0 { + // The first call, we return a successful result. + callCount++ + return ReturnValues{ + models.ProjectResult{ + PlanSuccess: &models.PlanSuccess{}, + }, + } + } + // The second call, we return a failed result. + return ReturnValues{ + models.ProjectResult{ + Error: errors.New("err"), + }, + } + }) + tmp, cleanup := TempDir(t) + defer cleanup() + + When(workingDir.GetPullDir(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())). + ThenReturn(tmp, nil) + ch.RunAutoplanCommand(fixtures.GithubRepo, fixtures.GithubRepo, fixtures.Pull, fixtures.User) + pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) +} diff --git a/server/events/commit_status_updater_test.go b/server/events/commit_status_updater_test.go index 209b3f5493..3f6db3450f 100644 --- a/server/events/commit_status_updater_test.go +++ b/server/events/commit_status_updater_test.go @@ -108,20 +108,20 @@ func TestUpdateProjectResult(t *testing.T) { for _, c := range cases { t.Run(strings.Join(c.Statuses, "-"), func(t *testing.T) { - var results []events.ProjectResult + var results []models.ProjectResult for _, statusStr := range c.Statuses { - var result events.ProjectResult + var result models.ProjectResult switch statusStr { case "failure": - result = events.ProjectResult{ + result = models.ProjectResult{ Failure: "failure", } case "error": - result = events.ProjectResult{ + result = models.ProjectResult{ Error: errors.New("err"), } default: - result = events.ProjectResult{} + result = models.ProjectResult{} } results = append(results, result) } diff --git a/server/events/db/boltdb.go b/server/events/db/boltdb.go new file mode 100644 index 0000000000..98e771068d --- /dev/null +++ b/server/events/db/boltdb.go @@ -0,0 +1,407 @@ +// Package db handles our database layer. +package db + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/boltdb/bolt" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" + "os" + "path" + "strings" + "time" +) + +// BoltDB is a database using BoltDB +type BoltDB struct { + db *bolt.DB + locksBucketName []byte + pullsBucketName []byte +} + +const ( + locksBucketName = "runLocks" + pullsBucketName = "pulls" + pullKeySeparator = "::" +) + +// New returns a valid locker. We need to be able to write to dataDir +// since bolt stores its data as a file +func New(dataDir string) (*BoltDB, error) { + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, errors.Wrap(err, "creating data dir") + } + db, err := bolt.Open(path.Join(dataDir, "atlantis.db"), 0600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + if err.Error() == "timeout" { + return nil, errors.New("starting BoltDB: timeout (a possible cause is another Atlantis instance already running)") + } + return nil, errors.Wrap(err, "starting BoltDB") + } + + // Create the buckets. + err = db.Update(func(tx *bolt.Tx) error { + if _, err = tx.CreateBucketIfNotExists([]byte(locksBucketName)); err != nil { + return errors.Wrapf(err, "creating bucket %q", locksBucketName) + } + if _, err = tx.CreateBucketIfNotExists([]byte(pullsBucketName)); err != nil { + return errors.Wrapf(err, "creating bucket %q", pullsBucketName) + } + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "starting BoltDB") + } + // todo: close BoltDB when server is sigtermed + return &BoltDB{db: db, locksBucketName: []byte(locksBucketName), pullsBucketName: []byte(pullsBucketName)}, nil +} + +// NewWithDB is used for testing. +func NewWithDB(db *bolt.DB, bucket string) (*BoltDB, error) { + return &BoltDB{db: db, locksBucketName: []byte(bucket), pullsBucketName: []byte(pullsBucketName)}, nil +} + +// TryLock attempts to create a new lock. If the lock is +// acquired, it will return true and the lock returned will be newLock. +// If the lock is not acquired, it will return false and the current +// lock that is preventing this lock from being acquired. +func (b *BoltDB) TryLock(newLock models.ProjectLock) (bool, models.ProjectLock, error) { + var lockAcquired bool + var currLock models.ProjectLock + key := b.lockKey(newLock.Project, newLock.Workspace) + newLockSerialized, _ := json.Marshal(newLock) + transactionErr := b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.locksBucketName) + + // if there is no run at that key then we're free to create the lock + currLockSerialized := bucket.Get([]byte(key)) + if currLockSerialized == nil { + // This will only error on readonly buckets, it's okay to ignore. + bucket.Put([]byte(key), newLockSerialized) // nolint: errcheck + lockAcquired = true + currLock = newLock + return nil + } + + // otherwise the lock fails, return to caller the run that's holding the lock + if err := json.Unmarshal(currLockSerialized, &currLock); err != nil { + return errors.Wrap(err, "failed to deserialize current lock") + } + lockAcquired = false + return nil + }) + + if transactionErr != nil { + return false, currLock, errors.Wrap(transactionErr, "DB transaction failed") + } + + return lockAcquired, currLock, nil +} + +// Unlock attempts to unlock the project and workspace. +// If there is no lock, then it will return a nil pointer. +// If there is a lock, then it will delete it, and then return a pointer +// to the deleted lock. +func (b *BoltDB) Unlock(p models.Project, workspace string) (*models.ProjectLock, error) { + var lock models.ProjectLock + foundLock := false + key := b.lockKey(p, workspace) + err := b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.locksBucketName) + serialized := bucket.Get([]byte(key)) + if serialized != nil { + if err := json.Unmarshal(serialized, &lock); err != nil { + return errors.Wrap(err, "failed to deserialize lock") + } + foundLock = true + } + return bucket.Delete([]byte(key)) + }) + err = errors.Wrap(err, "DB transaction failed") + if foundLock { + return &lock, err + } + return nil, err +} + +// List lists all current locks. +func (b *BoltDB) List() ([]models.ProjectLock, error) { + var locks []models.ProjectLock + var locksBytes [][]byte + err := b.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.locksBucketName) + c := bucket.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + locksBytes = append(locksBytes, v) + } + return nil + }) + if err != nil { + return locks, errors.Wrap(err, "DB transaction failed") + } + + // deserialize bytes into the proper objects + for k, v := range locksBytes { + var lock models.ProjectLock + if err := json.Unmarshal(v, &lock); err != nil { + return locks, errors.Wrap(err, fmt.Sprintf("failed to deserialize lock at key %q", string(k))) + } + locks = append(locks, lock) + } + + return locks, nil +} + +// UnlockByPull deletes all locks associated with that pull request and returns them. +func (b *BoltDB) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { + var locks []models.ProjectLock + err := b.db.View(func(tx *bolt.Tx) error { + c := tx.Bucket(b.locksBucketName).Cursor() + + // we can use the repoFullName as a prefix search since that's the first part of the key + for k, v := c.Seek([]byte(repoFullName)); k != nil && bytes.HasPrefix(k, []byte(repoFullName)); k, v = c.Next() { + var lock models.ProjectLock + if err := json.Unmarshal(v, &lock); err != nil { + return errors.Wrapf(err, "deserializing lock at key %q", string(k)) + } + if lock.Pull.Num == pullNum { + locks = append(locks, lock) + } + } + return nil + }) + if err != nil { + return locks, err + } + + // delete the locks + for _, lock := range locks { + if _, err = b.Unlock(lock.Project, lock.Workspace); err != nil { + return locks, errors.Wrapf(err, "unlocking repo %s, path %s, workspace %s", lock.Project.RepoFullName, lock.Project.Path, lock.Workspace) + } + } + return locks, nil +} + +// GetLock returns a pointer to the lock for that project and workspace. +// If there is no lock, it returns a nil pointer. +func (b *BoltDB) GetLock(p models.Project, workspace string) (*models.ProjectLock, error) { + key := b.lockKey(p, workspace) + var lockBytes []byte + err := b.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(b.locksBucketName) + lockBytes = b.Get([]byte(key)) + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "getting lock data") + } + // lockBytes will be nil if there was no data at that key + if lockBytes == nil { + return nil, nil + } + + var lock models.ProjectLock + if err := json.Unmarshal(lockBytes, &lock); err != nil { + return nil, errors.Wrapf(err, "deserializing lock at key %q", key) + } + + // need to set it to Local after deserialization due to https://github.com/golang/go/issues/19486 + lock.Time = lock.Time.Local() + return &lock, nil +} + +// UpdatePullWithResults updates pull's status with the latest project results. +// It returns the new PullStatus object. +func (b *BoltDB) UpdatePullWithResults(pull models.PullRequest, newResults []models.ProjectResult) (*models.PullStatus, error) { + key, err := b.pullKey(pull) + if err != nil { + return nil, err + } + + var newStatus *models.PullStatus + err = b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.pullsBucketName) + currStatus, err := b.getPullFromBucket(bucket, key) + if err != nil { + return err + } + + // If there is no pull OR if the pull we have is out of date, we + // just write a new pull. + if currStatus == nil || currStatus.Pull.HeadCommit != pull.HeadCommit { + var statuses []models.ProjectStatus + for _, r := range newResults { + statuses = append(statuses, b.projectResultToProject(r)) + } + newStatus = &models.PullStatus{ + Pull: pull, + Projects: statuses, + } + } else { + // If there's an existing pull at the right commit then we have to + // merge our project results with the existing ones. We do a merge + // because it's possible a user is just applying a single project + // in this command and so we don't want to delete our data about + // other projects that aren't affected by this command. + newStatus = currStatus + for _, res := range newResults { + // First, check if we should update any existing projects. + updatedExisting := false + for i := range newStatus.Projects { + // NOTE: We're using a reference here because we are + // in-place updating its Status field. + proj := &newStatus.Projects[i] + if res.Workspace == proj.Workspace && + res.RepoRelDir == proj.RepoRelDir && + res.ProjectName == proj.ProjectName { + + proj.Status = b.getPlanStatus(res) + updatedExisting = true + break + } + } + + if !updatedExisting { + // If we didn't update an existing project, then we need to + // add this because it's a new one. + newStatus.Projects = append(newStatus.Projects, b.projectResultToProject(res)) + } + } + } + + // Now, we overwrite the key with our new status. + return b.writePullToBucket(bucket, key, newStatus) + }) + return newStatus, errors.Wrap(err, "DB transaction failed") +} + +// GetPullStatus returns the status for pull. +// If there is no status, returns a nil pointer. +func (b *BoltDB) GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) { + key, err := b.pullKey(pull) + if err != nil { + return nil, err + } + var s *models.PullStatus + err = b.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.pullsBucketName) + var txErr error + s, txErr = b.getPullFromBucket(bucket, key) + return txErr + }) + return s, errors.Wrap(err, "DB transaction failed") +} + +// DeletePullStatus deletes the status for pull. +func (b *BoltDB) DeletePullStatus(pull models.PullRequest) error { + key, err := b.pullKey(pull) + if err != nil { + return err + } + err = b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.pullsBucketName) + return bucket.Delete(key) + }) + return errors.Wrap(err, "DB transaction failed") +} + +// DeleteProjectStatus deletes all project statuses under pull that match +// workspace and repoRelDir. +func (b *BoltDB) DeleteProjectStatus(pull models.PullRequest, workspace string, repoRelDir string) error { + key, err := b.pullKey(pull) + if err != nil { + return err + } + err = b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.pullsBucketName) + currStatus, err := b.getPullFromBucket(bucket, key) + if err != nil { + return err + } + if currStatus == nil { + return nil + } + + // Create a new projectStatuses array without the ones we want to + // delete. + var newProjects []models.ProjectStatus + for _, p := range currStatus.Projects { + if p.Workspace == workspace && p.RepoRelDir == repoRelDir { + continue + } + newProjects = append(newProjects, p) + } + + // Overwrite the old pull status. + currStatus.Projects = newProjects + return b.writePullToBucket(bucket, key, currStatus) + }) + return errors.Wrap(err, "DB transaction failed") +} + +func (b *BoltDB) pullKey(pull models.PullRequest) ([]byte, error) { + hostname := pull.BaseRepo.VCSHost.Hostname + if strings.Contains(hostname, pullKeySeparator) { + return nil, fmt.Errorf("vcs hostname %q contains illegal string %q", hostname, pullKeySeparator) + } + repo := pull.BaseRepo.FullName + if strings.Contains(repo, pullKeySeparator) { + return nil, fmt.Errorf("repo name %q contains illegal string %q", hostname, pullKeySeparator) + } + + return []byte(fmt.Sprintf("%s::%s::%d", hostname, repo, pull.Num)), + nil +} + +func (b *BoltDB) lockKey(p models.Project, workspace string) string { + return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, workspace) +} + +func (b *BoltDB) getPullFromBucket(bucket *bolt.Bucket, key []byte) (*models.PullStatus, error) { + serialized := bucket.Get(key) + if serialized == nil { + return nil, nil + } + + var p models.PullStatus + if err := json.Unmarshal(serialized, &p); err != nil { + return nil, errors.Wrapf(err, "deserializing pull at %q with contents %q", key, serialized) + } + return &p, nil +} + +func (b *BoltDB) writePullToBucket(bucket *bolt.Bucket, key []byte, pull *models.PullStatus) error { + serialized, err := json.Marshal(pull) + if err != nil { + return errors.Wrap(err, "serializing") + } + return bucket.Put(key, serialized) +} + +func (b *BoltDB) getPlanStatus(p models.ProjectResult) models.ProjectPlanStatus { + if p.Error != nil { + return models.ErroredPlanStatus + } + if p.Failure != "" { + return models.ErroredPlanStatus + } + if p.PlanSuccess != nil { + return models.PlannedPlanStatus + } + if p.ApplySuccess != "" { + return models.AppliedPlanStatus + } + return models.ErroredPlanStatus +} + +func (b *BoltDB) projectResultToProject(p models.ProjectResult) models.ProjectStatus { + return models.ProjectStatus{ + Workspace: p.Workspace, + RepoRelDir: p.RepoRelDir, + ProjectName: p.ProjectName, + Status: b.getPlanStatus(p), + } +} diff --git a/server/events/locking/boltdb/boltdb_test.go b/server/events/db/boltdb_test.go similarity index 54% rename from server/events/locking/boltdb/boltdb_test.go rename to server/events/db/boltdb_test.go index 62acb63457..b65777db07 100644 --- a/server/events/locking/boltdb/boltdb_test.go +++ b/server/events/db/boltdb_test.go @@ -11,9 +11,10 @@ // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. -package boltdb_test +package db_test import ( + "github.com/runatlantis/atlantis/server/events/db" "io/ioutil" "os" "testing" @@ -21,7 +22,6 @@ import ( "github.com/boltdb/bolt" "github.com/pkg/errors" - "github.com/runatlantis/atlantis/server/events/locking/boltdb" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) @@ -352,8 +352,319 @@ func TestGetLock(t *testing.T) { Equals(t, lock.User, l.User) } +// Test we can create a status and then get it. +func TestPullStatus_UpdateGet(t *testing.T) { + b, cleanup := newTestDB2(t) + defer cleanup() + + pull := models.PullRequest{ + Num: 1, + HeadCommit: "sha", + URL: "url", + HeadBranch: "head", + BaseBranch: "base", + Author: "lkysow", + State: models.OpenPullState, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + CloneURL: "clone-url", + SanitizedCloneURL: "clone-url", + VCSHost: models.VCSHost{ + Hostname: "github.com", + Type: models.Github, + }, + }, + } + status, err := b.UpdatePullWithResults( + pull, + []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "default", + Failure: "failure", + }, + }) + Ok(t, err) + Assert(t, status != nil, "exp non-nil") + + status, err = b.GetPullStatus(pull) + Ok(t, err) + Equals(t, pull, status.Pull) + Equals(t, []models.ProjectStatus{ + { + Workspace: "default", + RepoRelDir: ".", + ProjectName: "", + Status: models.ErroredPlanStatus, + }, + }, status.Projects) +} + +// Test we can create a status, delete it, and then we shouldn't be able to get +// it. +func TestPullStatus_UpdateDeleteGet(t *testing.T) { + b, cleanup := newTestDB2(t) + defer cleanup() + + pull := models.PullRequest{ + Num: 1, + HeadCommit: "sha", + URL: "url", + HeadBranch: "head", + BaseBranch: "base", + Author: "lkysow", + State: models.OpenPullState, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + CloneURL: "clone-url", + SanitizedCloneURL: "clone-url", + VCSHost: models.VCSHost{ + Hostname: "github.com", + Type: models.Github, + }, + }, + } + status, err := b.UpdatePullWithResults( + pull, + []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "default", + Failure: "failure", + }, + }) + Ok(t, err) + Assert(t, status != nil, "exp non-nil") + + err = b.DeletePullStatus(pull) + Ok(t, err) + + status, err = b.GetPullStatus(pull) + Ok(t, err) + Assert(t, status == nil, "exp nil") +} + +// Test we can create a status, delete a specific project's status within that +// pull status, and when we get all the project statuses, that specific project +// should not be there. +func TestPullStatus_UpdateDeleteProject(t *testing.T) { + b, cleanup := newTestDB2(t) + defer cleanup() + + pull := models.PullRequest{ + Num: 1, + HeadCommit: "sha", + URL: "url", + HeadBranch: "head", + BaseBranch: "base", + Author: "lkysow", + State: models.OpenPullState, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + CloneURL: "clone-url", + SanitizedCloneURL: "clone-url", + VCSHost: models.VCSHost{ + Hostname: "github.com", + Type: models.Github, + }, + }, + } + status, err := b.UpdatePullWithResults( + pull, + []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "default", + Failure: "failure", + }, + { + RepoRelDir: ".", + Workspace: "staging", + ApplySuccess: "success!", + }, + }) + Ok(t, err) + Assert(t, status != nil, "exp non-nil") + + err = b.DeleteProjectStatus(pull, "default", ".") + Ok(t, err) + + status, err = b.GetPullStatus(pull) + Ok(t, err) + Equals(t, pull, status.Pull) + Equals(t, []models.ProjectStatus{ + { + Workspace: "staging", + RepoRelDir: ".", + ProjectName: "", + Status: models.AppliedPlanStatus, + }, + }, status.Projects) +} + +// Test that if we update an existing pull status and our new status is for a +// different HeadSHA, that we just overwrite the old status. +func TestPullStatus_UpdateNewCommit(t *testing.T) { + b, cleanup := newTestDB2(t) + defer cleanup() + + pull := models.PullRequest{ + Num: 1, + HeadCommit: "sha", + URL: "url", + HeadBranch: "head", + BaseBranch: "base", + Author: "lkysow", + State: models.OpenPullState, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + CloneURL: "clone-url", + SanitizedCloneURL: "clone-url", + VCSHost: models.VCSHost{ + Hostname: "github.com", + Type: models.Github, + }, + }, + } + status, err := b.UpdatePullWithResults( + pull, + []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "default", + Failure: "failure", + }, + }) + Ok(t, err) + Assert(t, status != nil, "exp non-nil") + + pull.HeadCommit = "newsha" + status, err = b.UpdatePullWithResults(pull, + []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "staging", + ApplySuccess: "success!", + }, + }) + + Ok(t, err) + Equals(t, 1, len(status.Projects)) + + status, err = b.GetPullStatus(pull) + Ok(t, err) + Equals(t, pull, status.Pull) + Equals(t, []models.ProjectStatus{ + { + Workspace: "staging", + RepoRelDir: ".", + ProjectName: "", + Status: models.AppliedPlanStatus, + }, + }, status.Projects) +} + +// Test that if we update an existing pull status and our new status is for a +// the same commit, that we merge the statuses. +func TestPullStatus_UpdateMerge(t *testing.T) { + b, cleanup := newTestDB2(t) + defer cleanup() + + pull := models.PullRequest{ + Num: 1, + HeadCommit: "sha", + URL: "url", + HeadBranch: "head", + BaseBranch: "base", + Author: "lkysow", + State: models.OpenPullState, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + CloneURL: "clone-url", + SanitizedCloneURL: "clone-url", + VCSHost: models.VCSHost{ + Hostname: "github.com", + Type: models.Github, + }, + }, + } + status, err := b.UpdatePullWithResults( + pull, + []models.ProjectResult{ + { + RepoRelDir: "mergeme", + Workspace: "default", + Failure: "failure", + }, + { + RepoRelDir: "staythesame", + Workspace: "default", + PlanSuccess: &models.PlanSuccess{ + TerraformOutput: "tf out", + LockURL: "lock-url", + RePlanCmd: "plan command", + ApplyCmd: "apply command", + }, + }, + }) + Ok(t, err) + Assert(t, status != nil, "exp non-nil") + + status, err = b.UpdatePullWithResults(pull, + []models.ProjectResult{ + { + RepoRelDir: "mergeme", + Workspace: "default", + ApplySuccess: "applied!", + }, + { + RepoRelDir: "newresult", + Workspace: "default", + ApplySuccess: "success!", + }, + }) + + Ok(t, err) + + getStatus, err := b.GetPullStatus(pull) + Ok(t, err) + + // Test both the pull state returned from the update call *and* the get + // call. + for _, s := range []*models.PullStatus{status, getStatus} { + Equals(t, pull, s.Pull) + Equals(t, []models.ProjectStatus{ + { + RepoRelDir: "mergeme", + Workspace: "default", + Status: models.AppliedPlanStatus, + }, + { + RepoRelDir: "staythesame", + Workspace: "default", + Status: models.PlannedPlanStatus, + }, + { + RepoRelDir: "newresult", + Workspace: "default", + Status: models.AppliedPlanStatus, + }, + }, status.Projects) + } +} + // newTestDB returns a TestDB using a temporary path. -func newTestDB() (*bolt.DB, *boltdb.BoltLocker) { +func newTestDB() (*bolt.DB, *db.BoltDB) { // Retrieve a temporary path. f, err := ioutil.TempFile("", "") if err != nil { @@ -363,11 +674,11 @@ func newTestDB() (*bolt.DB, *boltdb.BoltLocker) { f.Close() // nolint: errcheck // Open the database. - db, err := bolt.Open(path, 0600, nil) + boltDB, err := bolt.Open(path, 0600, nil) if err != nil { panic(errors.Wrap(err, "could not start bolt DB")) } - if err := db.Update(func(tx *bolt.Tx) error { + if err := boltDB.Update(func(tx *bolt.Tx) error { if _, err := tx.CreateBucketIfNotExists([]byte(lockBucket)); err != nil { return errors.Wrap(err, "failed to create bucket") } @@ -375,8 +686,17 @@ func newTestDB() (*bolt.DB, *boltdb.BoltLocker) { }); err != nil { panic(errors.Wrap(err, "could not create bucket")) } - b, _ := boltdb.NewWithDB(db, lockBucket) - return db, b + b, _ := db.NewWithDB(boltDB, lockBucket) + return boltDB, b +} + +func newTestDB2(t *testing.T) (*db.BoltDB, func()) { + tmp, cleanup := TempDir(t) + boltDB, err := db.New(tmp) + Ok(t, err) + return boltDB, func() { + cleanup() + } } func cleanupDB(db *bolt.DB) { diff --git a/server/events/locking/boltdb/boltdb.go b/server/events/locking/boltdb/boltdb.go deleted file mode 100644 index 5e34a3c633..0000000000 --- a/server/events/locking/boltdb/boltdb.go +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright 2017 HootSuite Media Inc. -// -// Licensed under the Apache License, Version 2.0 (the License); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an AS IS BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// Modified hereafter by contributors to runatlantis/atlantis. -// -// Package boltdb provides a locking implementation using Bolt. -// Bolt is a key/value store that writes all data to a file. -// See https://github.com/boltdb/bolt for more information. -package boltdb - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path" - "time" - - "github.com/boltdb/bolt" - "github.com/pkg/errors" - "github.com/runatlantis/atlantis/server/events/models" -) - -// BoltLocker is a locking backend using BoltDB -type BoltLocker struct { - db *bolt.DB - bucket []byte -} - -const bucketName = "runLocks" - -// New returns a valid locker. We need to be able to write to dataDir -// since bolt stores its data as a file -func New(dataDir string) (*BoltLocker, error) { - if err := os.MkdirAll(dataDir, 0700); err != nil { - return nil, errors.Wrap(err, "creating data dir") - } - db, err := bolt.Open(path.Join(dataDir, "atlantis.db"), 0600, &bolt.Options{Timeout: 1 * time.Second}) - if err != nil { - if err.Error() == "timeout" { - return nil, errors.New("starting BoltDB: timeout (a possible cause is another Atlantis instance already running)") - } - return nil, errors.Wrap(err, "starting BoltDB") - } - err = db.Update(func(tx *bolt.Tx) error { - if _, err = tx.CreateBucketIfNotExists([]byte(bucketName)); err != nil { - return errors.Wrapf(err, "creating %q bucketName", bucketName) - } - return nil - }) - if err != nil { - return nil, errors.Wrap(err, "starting BoltDB") - } - // todo: close BoltDB when server is sigtermed - return &BoltLocker{db, []byte(bucketName)}, nil -} - -// NewWithDB is used for testing. -func NewWithDB(db *bolt.DB, bucket string) (*BoltLocker, error) { - return &BoltLocker{db, []byte(bucket)}, nil -} - -// TryLock attempts to create a new lock. If the lock is -// acquired, it will return true and the lock returned will be newLock. -// If the lock is not acquired, it will return false and the current -// lock that is preventing this lock from being acquired. -func (b *BoltLocker) TryLock(newLock models.ProjectLock) (bool, models.ProjectLock, error) { - var lockAcquired bool - var currLock models.ProjectLock - key := b.key(newLock.Project, newLock.Workspace) - newLockSerialized, _ := json.Marshal(newLock) - transactionErr := b.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket(b.bucket) - - // if there is no run at that key then we're free to create the lock - currLockSerialized := bucket.Get([]byte(key)) - if currLockSerialized == nil { - // This will only error on readonly buckets, it's okay to ignore. - bucket.Put([]byte(key), newLockSerialized) // nolint: errcheck - lockAcquired = true - currLock = newLock - return nil - } - - // otherwise the lock fails, return to caller the run that's holding the lock - if err := json.Unmarshal(currLockSerialized, &currLock); err != nil { - return errors.Wrap(err, "failed to deserialize current lock") - } - lockAcquired = false - return nil - }) - - if transactionErr != nil { - return false, currLock, errors.Wrap(transactionErr, "DB transaction failed") - } - - return lockAcquired, currLock, nil -} - -// Unlock attempts to unlock the project and workspace. -// If there is no lock, then it will return a nil pointer. -// If there is a lock, then it will delete it, and then return a pointer -// to the deleted lock. -func (b BoltLocker) Unlock(p models.Project, workspace string) (*models.ProjectLock, error) { - var lock models.ProjectLock - foundLock := false - key := b.key(p, workspace) - err := b.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket(b.bucket) - serialized := bucket.Get([]byte(key)) - if serialized != nil { - if err := json.Unmarshal(serialized, &lock); err != nil { - return errors.Wrap(err, "failed to deserialize lock") - } - foundLock = true - } - return bucket.Delete([]byte(key)) - }) - err = errors.Wrap(err, "DB transaction failed") - if foundLock { - return &lock, err - } - return nil, err -} - -// List lists all current locks. -func (b BoltLocker) List() ([]models.ProjectLock, error) { - var locks []models.ProjectLock - var locksBytes [][]byte - err := b.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket(b.bucket) - c := bucket.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - locksBytes = append(locksBytes, v) - } - return nil - }) - if err != nil { - return locks, errors.Wrap(err, "DB transaction failed") - } - - // deserialize bytes into the proper objects - for k, v := range locksBytes { - var lock models.ProjectLock - if err := json.Unmarshal(v, &lock); err != nil { - return locks, errors.Wrap(err, fmt.Sprintf("failed to deserialize lock at key %q", string(k))) - } - locks = append(locks, lock) - } - - return locks, nil -} - -// UnlockByPull deletes all locks associated with that pull request and returns them. -func (b BoltLocker) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { - var locks []models.ProjectLock - err := b.db.View(func(tx *bolt.Tx) error { - c := tx.Bucket(b.bucket).Cursor() - - // we can use the repoFullName as a prefix search since that's the first part of the key - for k, v := c.Seek([]byte(repoFullName)); k != nil && bytes.HasPrefix(k, []byte(repoFullName)); k, v = c.Next() { - var lock models.ProjectLock - if err := json.Unmarshal(v, &lock); err != nil { - return errors.Wrapf(err, "deserializing lock at key %q", string(k)) - } - if lock.Pull.Num == pullNum { - locks = append(locks, lock) - } - } - return nil - }) - if err != nil { - return locks, err - } - - // delete the locks - for _, lock := range locks { - if _, err = b.Unlock(lock.Project, lock.Workspace); err != nil { - return locks, errors.Wrapf(err, "unlocking repo %s, path %s, workspace %s", lock.Project.RepoFullName, lock.Project.Path, lock.Workspace) - } - } - return locks, nil -} - -// GetLock returns a pointer to the lock for that project and workspace. -// If there is no lock, it returns a nil pointer. -func (b BoltLocker) GetLock(p models.Project, workspace string) (*models.ProjectLock, error) { - key := b.key(p, workspace) - var lockBytes []byte - err := b.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket(b.bucket) - lockBytes = b.Get([]byte(key)) - return nil - }) - if err != nil { - return nil, errors.Wrap(err, "getting lock data") - } - // lockBytes will be nil if there was no data at that key - if lockBytes == nil { - return nil, nil - } - - var lock models.ProjectLock - if err := json.Unmarshal(lockBytes, &lock); err != nil { - return nil, errors.Wrapf(err, "deserializing lock at key %q", key) - } - - // need to set it to Local after deserialization due to https://github.com/golang/go/issues/19486 - lock.Time = lock.Time.Local() - return &lock, nil -} - -func (b BoltLocker) key(p models.Project, workspace string) string { - return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, workspace) -} diff --git a/server/events/markdown_renderer.go b/server/events/markdown_renderer.go index 7b88f80b4a..0842653428 100644 --- a/server/events/markdown_renderer.go +++ b/server/events/markdown_renderer.go @@ -39,29 +39,35 @@ type MarkdownRenderer struct { GitlabSupportsCommonMark bool } -// CommonData is data that all responses have. -type CommonData struct { - Command string - Verbose bool - Log string +// commonData is data that all responses have. +type commonData struct { + Command string + Verbose bool + Log string + PlansDeleted bool } -// ErrData is data about an error response. -type ErrData struct { +// errData is data about an error response. +type errData struct { Error string - CommonData + commonData } -// FailureData is data about a failure response. -type FailureData struct { +// failureData is data about a failure response. +type failureData struct { Failure string - CommonData + commonData } -// ResultData is data about a successful response. -type ResultData struct { +// resultData is data about a successful response. +type resultData struct { Results []projectResultTmplData - CommonData + commonData +} + +type planSuccessData struct { + models.PlanSuccess + PlanWasDeleted bool } type projectResultTmplData struct { @@ -75,17 +81,22 @@ type projectResultTmplData struct { // nolint: interfacer func (m *MarkdownRenderer) Render(res CommandResult, cmdName CommandName, log string, verbose bool, vcsHost models.VCSHostType) string { commandStr := strings.Title(cmdName.String()) - common := CommonData{commandStr, verbose, log} + common := commonData{ + Command: commandStr, + Verbose: verbose, + Log: log, + PlansDeleted: res.PlansDeleted, + } if res.Error != nil { - return m.renderTemplate(unwrappedErrWithLogTmpl, ErrData{res.Error.Error(), common}) + return m.renderTemplate(unwrappedErrWithLogTmpl, errData{res.Error.Error(), common}) } if res.Failure != "" { - return m.renderTemplate(failureWithLogTmpl, FailureData{res.Failure, common}) + return m.renderTemplate(failureWithLogTmpl, failureData{res.Failure, common}) } return m.renderProjectResults(res.ProjectResults, common, vcsHost) } -func (m *MarkdownRenderer) renderProjectResults(results []ProjectResult, common CommonData, vcsHost models.VCSHostType) string { +func (m *MarkdownRenderer) renderProjectResults(results []models.ProjectResult, common commonData, vcsHost models.VCSHostType) string { var resultsTmplData []projectResultTmplData numPlanSuccesses := 0 @@ -117,9 +128,9 @@ func (m *MarkdownRenderer) renderProjectResults(results []ProjectResult, common }) } else if result.PlanSuccess != nil { if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { - resultData.Rendered = m.renderTemplate(planSuccessWrappedTmpl, *result.PlanSuccess) + resultData.Rendered = m.renderTemplate(planSuccessWrappedTmpl, planSuccessData{PlanSuccess: *result.PlanSuccess, PlanWasDeleted: common.PlansDeleted}) } else { - resultData.Rendered = m.renderTemplate(planSuccessUnwrappedTmpl, *result.PlanSuccess) + resultData.Rendered = m.renderTemplate(planSuccessUnwrappedTmpl, planSuccessData{PlanSuccess: *result.PlanSuccess, PlanWasDeleted: common.PlansDeleted}) } numPlanSuccesses++ } else if result.ApplySuccess != "" { @@ -150,7 +161,7 @@ func (m *MarkdownRenderer) renderProjectResults(results []ProjectResult, common default: return "no template matched–this is a bug" } - return m.renderTemplate(tmpl, ResultData{resultsTmplData, common}) + return m.renderTemplate(tmpl, resultData{resultsTmplData, common}) } // shouldUseWrappedTmpl returns true if we should use the wrapped markdown @@ -198,7 +209,7 @@ var multiProjectPlanTmpl = template.Must(template.New("").Funcs(sprig.TxtFuncMap "{{ range $i, $result := .Results }}" + "### {{add $i 1}}. {{ if $result.ProjectName }}project: `{{$result.ProjectName}}` {{ end }}dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}`\n" + "{{$result.Rendered}}\n\n" + - "---\n{{end}}{{ if gt (len .Results) 0 }}* :fast_forward: To **apply** all unapplied plans from this pull request, comment:\n" + + "---\n{{end}}{{ if and (gt (len .Results) 0) (not .PlansDeleted) }}* :fast_forward: To **apply** all unapplied plans from this pull request, comment:\n" + " * `atlantis apply`{{end}}" + logTmpl)) var multiProjectApplyTmpl = template.Must(template.New("").Funcs(sprig.TxtFuncMap()).Parse( @@ -225,11 +236,11 @@ var planSuccessWrappedTmpl = template.Must(template.New("").Parse( // planNextSteps are instructions appended after successful plans as to what // to do next. -var planNextSteps = "* :arrow_forward: To **apply** this plan, comment:\n" + +var planNextSteps = "{{ if .PlanWasDeleted }}This plan was not saved because one or more projects failed and automerge requires all plans pass.{{ else }}* :arrow_forward: To **apply** this plan, comment:\n" + " * `{{.ApplyCmd}}`\n" + "* :put_litter_in_its_place: To **delete** this plan click [here]({{.LockURL}})\n" + "* :repeat: To **plan** this project again, comment:\n" + - " * `{{.RePlanCmd}}`" + " * `{{.RePlanCmd}}`{{end}}" var applyUnwrappedSuccessTmpl = template.Must(template.New("").Parse( "```diff\n" + "{{.Output}}\n" + diff --git a/server/events/markdown_renderer_test.go b/server/events/markdown_renderer_test.go index 07d6c7b8d4..7b4b3aad7b 100644 --- a/server/events/markdown_renderer_test.go +++ b/server/events/markdown_renderer_test.go @@ -117,23 +117,23 @@ func TestRenderProjectResults(t *testing.T) { cases := []struct { Description string Command events.CommandName - ProjectResults []events.ProjectResult + ProjectResults []models.ProjectResult VCSHost models.VCSHostType Expected string }{ { "no projects", events.PlanCommand, - []events.ProjectResult{}, + []models.ProjectResult{}, models.Github, "Ran Plan for 0 projects:\n\n\n", }, { "single successful plan", events.PlanCommand, - []events.ProjectResult{ + []models.ProjectResult{ { - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", @@ -164,9 +164,9 @@ $$$ { "single successful plan with project name", events.PlanCommand, - []events.ProjectResult{ + []models.ProjectResult{ { - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", @@ -198,7 +198,7 @@ $$$ { "single successful apply", events.ApplyCommand, - []events.ProjectResult{ + []models.ProjectResult{ { ApplySuccess: "success", Workspace: "workspace", @@ -217,7 +217,7 @@ $$$ { "single successful apply with project name", events.ApplyCommand, - []events.ProjectResult{ + []models.ProjectResult{ { ApplySuccess: "success", Workspace: "workspace", @@ -237,11 +237,11 @@ $$$ { "multiple successful plans", events.PlanCommand, - []events.ProjectResult{ + []models.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", @@ -252,7 +252,7 @@ $$$ Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output2", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", @@ -296,7 +296,7 @@ $$$ { "multiple successful applies", events.ApplyCommand, - []events.ProjectResult{ + []models.ProjectResult{ { RepoRelDir: "path", Workspace: "workspace", @@ -332,7 +332,7 @@ $$$ { "single errored plan", events.PlanCommand, - []events.ProjectResult{ + []models.ProjectResult{ { Error: errors.New("error"), RepoRelDir: "path", @@ -352,7 +352,7 @@ $$$ { "single failed plan", events.PlanCommand, - []events.ProjectResult{ + []models.ProjectResult{ { RepoRelDir: "path", Workspace: "workspace", @@ -369,11 +369,11 @@ $$$ { "successful, failed, and errored plan", events.PlanCommand, - []events.ProjectResult{ + []models.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", @@ -428,7 +428,7 @@ $$$ { "successful, failed, and errored apply", events.ApplyCommand, - []events.ProjectResult{ + []models.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", @@ -474,7 +474,7 @@ $$$ { "successful, failed, and errored apply", events.ApplyCommand, - []events.ProjectResult{ + []models.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", @@ -613,7 +613,7 @@ func TestRenderProjectResults_WrappedErr(t *testing.T) { } rendered := mr.Render(events.CommandResult{ - ProjectResults: []events.ProjectResult{ + ProjectResults: []models.ProjectResult{ { RepoRelDir: ".", Workspace: "default", @@ -723,13 +723,13 @@ func TestRenderProjectResults_WrapSingleProject(t *testing.T) { mr := events.MarkdownRenderer{ GitlabSupportsCommonMark: c.GitlabCommonMarkSupport, } - var pr events.ProjectResult + var pr models.ProjectResult switch cmd { case events.PlanCommand: - pr = events.ProjectResult{ + pr = models.ProjectResult{ RepoRelDir: ".", Workspace: "default", - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: c.Output, LockURL: "lock-url", RePlanCmd: "replancmd", @@ -737,14 +737,14 @@ func TestRenderProjectResults_WrapSingleProject(t *testing.T) { }, } case events.ApplyCommand: - pr = events.ProjectResult{ + pr = models.ProjectResult{ RepoRelDir: ".", Workspace: "default", ApplySuccess: c.Output, } } rendered := mr.Render(events.CommandResult{ - ProjectResults: []events.ProjectResult{pr}, + ProjectResults: []models.ProjectResult{pr}, }, cmd, "log", false, c.VCSHost) // Check result. @@ -823,7 +823,7 @@ func TestRenderProjectResults_MultiProjectApplyWrapped(t *testing.T) { mr := events.MarkdownRenderer{} tfOut := strings.Repeat("line\n", 13) rendered := mr.Render(events.CommandResult{ - ProjectResults: []events.ProjectResult{ + ProjectResults: []models.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", @@ -868,11 +868,11 @@ func TestRenderProjectResults_MultiProjectPlanWrapped(t *testing.T) { mr := events.MarkdownRenderer{} tfOut := strings.Repeat("line\n", 13) rendered := mr.Render(events.CommandResult{ - ProjectResults: []events.ProjectResult{ + ProjectResults: []models.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: tfOut, LockURL: "staging-lock-url", ApplyCmd: "staging-apply-cmd", @@ -882,7 +882,7 @@ func TestRenderProjectResults_MultiProjectPlanWrapped(t *testing.T) { { RepoRelDir: ".", Workspace: "production", - PlanSuccess: &events.PlanSuccess{ + PlanSuccess: &models.PlanSuccess{ TerraformOutput: tfOut, LockURL: "production-lock-url", ApplyCmd: "production-apply-cmd", @@ -931,3 +931,110 @@ $$$ expWithBackticks := strings.Replace(exp, "$", "`", -1) Equals(t, expWithBackticks, rendered) } + +// Test rendering when there was an error in one of the plans and we deleted +// all the plans as a result. +func TestRenderProjectResults_PlansDeleted(t *testing.T) { + cases := map[string]struct { + cr events.CommandResult + exp string + }{ + "one failure": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "staging", + Failure: "failure", + }, + }, + PlansDeleted: true, + }, + exp: `Ran Plan for dir: $.$ workspace: $staging$ + +**Plan Failed**: failure + +`, + }, + "two failures": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "staging", + Failure: "failure", + }, + { + RepoRelDir: ".", + Workspace: "production", + Failure: "failure", + }, + }, + PlansDeleted: true, + }, + exp: `Ran Plan for 2 projects: +1. dir: $.$ workspace: $staging$ +1. dir: $.$ workspace: $production$ + +### 1. dir: $.$ workspace: $staging$ +**Plan Failed**: failure + +--- +### 2. dir: $.$ workspace: $production$ +**Plan Failed**: failure + +--- + +`, + }, + "one failure, one success": { + cr: events.CommandResult{ + ProjectResults: []models.ProjectResult{ + { + RepoRelDir: ".", + Workspace: "staging", + Failure: "failure", + }, + { + RepoRelDir: ".", + Workspace: "production", + PlanSuccess: &models.PlanSuccess{ + TerraformOutput: "tf out", + LockURL: "lock-url", + RePlanCmd: "re-plan cmd", + ApplyCmd: "apply cmd", + }, + }, + }, + PlansDeleted: true, + }, + exp: `Ran Plan for 2 projects: +1. dir: $.$ workspace: $staging$ +1. dir: $.$ workspace: $production$ + +### 1. dir: $.$ workspace: $staging$ +**Plan Failed**: failure + +--- +### 2. dir: $.$ workspace: $production$ +$$$diff +tf out +$$$ + +This plan was not saved because one or more projects failed and automerge requires all plans pass. + +--- + +`, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + mr := events.MarkdownRenderer{} + rendered := mr.Render(c.cr, events.PlanCommand, "log", false, models.Github) + expWithBackticks := strings.Replace(c.exp, "$", "`", -1) + Equals(t, expWithBackticks, rendered) + }) + } +} diff --git a/server/events/mocks/matchers/events_projectresult.go b/server/events/mocks/matchers/events_projectresult.go index 02d866b3bc..937d83ee9c 100644 --- a/server/events/mocks/matchers/events_projectresult.go +++ b/server/events/mocks/matchers/events_projectresult.go @@ -2,19 +2,19 @@ package matchers import ( - "reflect" "github.com/petergtz/pegomock" - events "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/models" + "reflect" ) -func AnyEventsProjectResult() events.ProjectResult { - pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(events.ProjectResult))(nil)).Elem())) - var nullValue events.ProjectResult +func AnyEventsProjectResult() models.ProjectResult { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.ProjectResult))(nil)).Elem())) + var nullValue models.ProjectResult return nullValue } -func EqEventsProjectResult(value events.ProjectResult) events.ProjectResult { +func EqEventsProjectResult(value models.ProjectResult) models.ProjectResult { pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) - var nullValue events.ProjectResult + var nullValue models.ProjectResult return nullValue } diff --git a/server/events/mocks/matchers/slice_of_events_pendingplan.go b/server/events/mocks/matchers/slice_of_events_pendingplan.go new file mode 100644 index 0000000000..f6b3d9e1e8 --- /dev/null +++ b/server/events/mocks/matchers/slice_of_events_pendingplan.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + events "github.com/runatlantis/atlantis/server/events" +) + +func AnySliceOfEventsPendingPlan() []events.PendingPlan { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*([]events.PendingPlan))(nil)).Elem())) + var nullValue []events.PendingPlan + return nullValue +} + +func EqSliceOfEventsPendingPlan(value []events.PendingPlan) []events.PendingPlan { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue []events.PendingPlan + return nullValue +} diff --git a/server/events/mocks/mock_pending_plan_finder.go b/server/events/mocks/mock_pending_plan_finder.go new file mode 100644 index 0000000000..c673fcb99e --- /dev/null +++ b/server/events/mocks/mock_pending_plan_finder.go @@ -0,0 +1,144 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events (interfaces: PendingPlanFinder) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + events "github.com/runatlantis/atlantis/server/events" + "reflect" + "time" +) + +type MockPendingPlanFinder struct { + fail func(message string, callerSkip ...int) +} + +func NewMockPendingPlanFinder() *MockPendingPlanFinder { + return &MockPendingPlanFinder{fail: pegomock.GlobalFailHandler} +} + +func (mock *MockPendingPlanFinder) Find(pullDir string) ([]events.PendingPlan, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockPendingPlanFinder().") + } + params := []pegomock.Param{pullDir} + result := pegomock.GetGenericMockFrom(mock).Invoke("Find", params, []reflect.Type{reflect.TypeOf((*[]events.PendingPlan)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 []events.PendingPlan + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].([]events.PendingPlan) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockPendingPlanFinder) DeletePlans(pullDir string) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockPendingPlanFinder().") + } + params := []pegomock.Param{pullDir} + result := pegomock.GetGenericMockFrom(mock).Invoke("DeletePlans", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockPendingPlanFinder) VerifyWasCalledOnce() *VerifierPendingPlanFinder { + return &VerifierPendingPlanFinder{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockPendingPlanFinder) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierPendingPlanFinder { + return &VerifierPendingPlanFinder{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockPendingPlanFinder) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierPendingPlanFinder { + return &VerifierPendingPlanFinder{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockPendingPlanFinder) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierPendingPlanFinder { + return &VerifierPendingPlanFinder{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierPendingPlanFinder struct { + mock *MockPendingPlanFinder + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierPendingPlanFinder) Find(pullDir string) *PendingPlanFinder_Find_OngoingVerification { + params := []pegomock.Param{pullDir} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Find", params, verifier.timeout) + return &PendingPlanFinder_Find_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type PendingPlanFinder_Find_OngoingVerification struct { + mock *MockPendingPlanFinder + methodInvocations []pegomock.MethodInvocation +} + +func (c *PendingPlanFinder_Find_OngoingVerification) GetCapturedArguments() string { + pullDir := c.GetAllCapturedArguments() + return pullDir[len(pullDir)-1] +} + +func (c *PendingPlanFinder_Find_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} + +func (verifier *VerifierPendingPlanFinder) DeletePlans(pullDir string) *PendingPlanFinder_DeletePlans_OngoingVerification { + params := []pegomock.Param{pullDir} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeletePlans", params, verifier.timeout) + return &PendingPlanFinder_DeletePlans_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type PendingPlanFinder_DeletePlans_OngoingVerification struct { + mock *MockPendingPlanFinder + methodInvocations []pegomock.MethodInvocation +} + +func (c *PendingPlanFinder_DeletePlans_OngoingVerification) GetCapturedArguments() string { + pullDir := c.GetAllCapturedArguments() + return pullDir[len(pullDir)-1] +} + +func (c *PendingPlanFinder_DeletePlans_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} diff --git a/server/events/mocks/mock_project_command_runner.go b/server/events/mocks/mock_project_command_runner.go index 45e2b0e88c..291559a042 100644 --- a/server/events/mocks/mock_project_command_runner.go +++ b/server/events/mocks/mock_project_command_runner.go @@ -4,9 +4,8 @@ package mocks import ( - pegomock "github.com/petergtz/pegomock" - events "github.com/runatlantis/atlantis/server/events" - models "github.com/runatlantis/atlantis/server/events/models" + "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) @@ -19,31 +18,31 @@ func NewMockProjectCommandRunner() *MockProjectCommandRunner { return &MockProjectCommandRunner{fail: pegomock.GlobalFailHandler} } -func (mock *MockProjectCommandRunner) Plan(ctx models.ProjectCommandContext) events.ProjectResult { +func (mock *MockProjectCommandRunner) Plan(ctx models.ProjectCommandContext) models.ProjectResult { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } params := []pegomock.Param{ctx} - result := pegomock.GetGenericMockFrom(mock).Invoke("Plan", params, []reflect.Type{reflect.TypeOf((*events.ProjectResult)(nil)).Elem()}) - var ret0 events.ProjectResult + result := pegomock.GetGenericMockFrom(mock).Invoke("Plan", params, []reflect.Type{reflect.TypeOf((*models.ProjectResult)(nil)).Elem()}) + var ret0 models.ProjectResult if len(result) != 0 { if result[0] != nil { - ret0 = result[0].(events.ProjectResult) + ret0 = result[0].(models.ProjectResult) } } return ret0 } -func (mock *MockProjectCommandRunner) Apply(ctx models.ProjectCommandContext) events.ProjectResult { +func (mock *MockProjectCommandRunner) Apply(ctx models.ProjectCommandContext) models.ProjectResult { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } params := []pegomock.Param{ctx} - result := pegomock.GetGenericMockFrom(mock).Invoke("Apply", params, []reflect.Type{reflect.TypeOf((*events.ProjectResult)(nil)).Elem()}) - var ret0 events.ProjectResult + result := pegomock.GetGenericMockFrom(mock).Invoke("Apply", params, []reflect.Type{reflect.TypeOf((*models.ProjectResult)(nil)).Elem()}) + var ret0 models.ProjectResult if len(result) != 0 { if result[0] != nil { - ret0 = result[0].(events.ProjectResult) + ret0 = result[0].(models.ProjectResult) } } return ret0 diff --git a/server/events/models/models.go b/server/events/models/models.go index 9f571a61d3..a9eee5a141 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -330,3 +330,89 @@ func (p *ProjectCommandContext) GetProjectName() string { } return "" } + +// ProjectResult is the result of executing a plan/apply for a specific project. +type ProjectResult struct { + RepoRelDir string + Workspace string + Error error + Failure string + PlanSuccess *PlanSuccess + ApplySuccess string + ProjectName string +} + +// Status returns the vcs commit status of this project result. +func (p ProjectResult) Status() CommitStatus { + if p.Error != nil { + return FailedCommitStatus + } + if p.Failure != "" { + return FailedCommitStatus + } + return SuccessCommitStatus +} + +// IsSuccessful returns true if this project result had no errors. +func (p ProjectResult) IsSuccessful() bool { + return p.PlanSuccess != nil || p.ApplySuccess != "" +} + +// PlanSuccess is the result of a successful plan. +type PlanSuccess struct { + // TerraformOutput is the output from Terraform of running plan. + TerraformOutput string + // LockURL is the full URL to the lock held by this plan. + LockURL string + // RePlanCmd is the command that users should run to re-plan this project. + RePlanCmd string + // ApplyCmd is the command that users should run to apply this plan. + ApplyCmd string +} + +// PullStatus is the current status of a pull request that is in progress. +type PullStatus struct { + // Projects are the projects that have been modified in this pull request. + Projects []ProjectStatus + // Pull is the original pull request model. + Pull PullRequest +} + +// ProjectStatus is the status of a specific project. +type ProjectStatus struct { + Workspace string + RepoRelDir string + ProjectName string + // Status is the status of where this project is at in the planning cycle. + Status ProjectPlanStatus +} + +// ProjectPlanStatus is the status of where this project is at in the planning +// cycle. +type ProjectPlanStatus int + +const ( + // ErroredPlanStatus means that this plan has an error or the apply has an + // error. + ErroredPlanStatus ProjectPlanStatus = iota + // PlannedPlanStatus means that a plan has been successfully generated but + // not yet applied. + PlannedPlanStatus + // AppliedPlanStatus means that a plan has been generated and applied + // successfully. + AppliedPlanStatus +) + +// String returns a string representation of the status. +func (p ProjectPlanStatus) String() string { + switch p { + case ErroredPlanStatus: + return "errored" + case PlannedPlanStatus: + return "planned" + case AppliedPlanStatus: + return "applied" + default: + return "errored" + } +} diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go index 054d1a3145..85c8864fd8 100644 --- a/server/events/models/models_test.go +++ b/server/events/models/models_test.go @@ -14,6 +14,7 @@ package models_test import ( + "errors" "fmt" "testing" @@ -255,3 +256,41 @@ func TestSplitRepoFullName(t *testing.T) { }) } } + +func TestProjectResult_IsSuccessful(t *testing.T) { + cases := map[string]struct { + pr models.ProjectResult + exp bool + }{ + "plan success": { + models.ProjectResult{ + PlanSuccess: &models.PlanSuccess{}, + }, + true, + }, + "apply success": { + models.ProjectResult{ + ApplySuccess: "success", + }, + true, + }, + "failure": { + models.ProjectResult{ + Failure: "failure", + }, + false, + }, + "error": { + models.ProjectResult{ + Error: errors.New("error"), + }, + false, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + Equals(t, c.exp, c.pr.IsSuccessful()) + }) + } +} diff --git a/server/events/pending_plan_finder.go b/server/events/pending_plan_finder.go index 3db0cd7745..8dd818104f 100644 --- a/server/events/pending_plan_finder.go +++ b/server/events/pending_plan_finder.go @@ -2,6 +2,7 @@ package events import ( "io/ioutil" + "os" "os/exec" "path/filepath" "strings" @@ -9,8 +10,15 @@ import ( "github.com/pkg/errors" ) -// PendingPlanFinder finds unapplied plans. -type PendingPlanFinder struct{} +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_pending_plan_finder.go PendingPlanFinder + +type PendingPlanFinder interface { + Find(pullDir string) ([]PendingPlan, error) + DeletePlans(pullDir string) error +} + +// DefaultPendingPlanFinder finds unapplied plans. +type DefaultPendingPlanFinder struct{} // PendingPlan is a plan that has not been applied. type PendingPlan struct { @@ -27,12 +35,18 @@ type PendingPlan struct { // Find finds all pending plans in pullDir. pullDir should be the working // directory where Atlantis will operate on this pull request. It's one level // up from where Atlantis clones the repo for each workspace. -func (p *PendingPlanFinder) Find(pullDir string) ([]PendingPlan, error) { +func (p *DefaultPendingPlanFinder) Find(pullDir string) ([]PendingPlan, error) { + plans, _, err := p.findWithAbsPaths(pullDir) + return plans, err +} + +func (p *DefaultPendingPlanFinder) findWithAbsPaths(pullDir string) ([]PendingPlan, []string, error) { workspaceDirs, err := ioutil.ReadDir(pullDir) if err != nil { - return nil, err + return nil, nil, err } var plans []PendingPlan + var absPaths []string for _, workspaceDir := range workspaceDirs { workspace := workspaceDir.Name() repoDir := filepath.Join(pullDir, workspace) @@ -43,7 +57,7 @@ func (p *PendingPlanFinder) Find(pullDir string) ([]PendingPlan, error) { lsCmd.Dir = repoDir lsOut, err := lsCmd.CombinedOutput() if err != nil { - return nil, errors.Wrapf(err, "running git ls-files . "+ + return nil, nil, errors.Wrapf(err, "running git ls-files . "+ "--others: %s", string(lsOut)) } for _, file := range strings.Split(string(lsOut), "\n") { @@ -54,8 +68,23 @@ func (p *PendingPlanFinder) Find(pullDir string) ([]PendingPlan, error) { RepoRelDir: repoRelDir, Workspace: workspace, }) + absPaths = append(absPaths, filepath.Join(repoDir, file)) } } } - return plans, nil + return plans, absPaths, nil +} + +// deletePlans deletes all plans in pullDir. +func (p *DefaultPendingPlanFinder) DeletePlans(pullDir string) error { + _, absPaths, err := p.findWithAbsPaths(pullDir) + if err != nil { + return err + } + for _, path := range absPaths { + if err := os.Remove(path); err != nil { + return errors.Wrapf(err, "delete plan at %s", path) + } + } + return nil } diff --git a/server/events/pending_plan_finder_test.go b/server/events/pending_plan_finder_test.go index b010cd38a3..13c36a48cd 100644 --- a/server/events/pending_plan_finder_test.go +++ b/server/events/pending_plan_finder_test.go @@ -1,6 +1,7 @@ package events_test import ( + "os" "os/exec" "path/filepath" "strings" @@ -12,7 +13,7 @@ import ( // If the dir doesn't exist should get an error. func TestPendingPlanFinder_FindNoDir(t *testing.T) { - pf := &events.PendingPlanFinder{} + pf := &events.DefaultPendingPlanFinder{} _, err := pf.Find("/doesntexist") ErrEquals(t, "open /doesntexist: no such file or directory", err) } @@ -140,7 +141,7 @@ func TestPendingPlanFinder_Find(t *testing.T) { }, } - pf := &events.PendingPlanFinder{} + pf := &events.DefaultPendingPlanFinder{} for _, c := range cases { t.Run(c.description, func(t *testing.T) { tmpDir, cleanup := DirStructure(t, c.files) @@ -186,12 +187,55 @@ func TestPendingPlanFinder_FindPlanCheckedIn(t *testing.T) { runCmd(t, repoDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, repoDir, "git", "commit", "--no-gpg-sign", "-m", "initial commit") - pf := &events.PendingPlanFinder{} + pf := &events.DefaultPendingPlanFinder{} actPlans, err := pf.Find(tmpDir) Ok(t, err) Equals(t, 0, len(actPlans)) } +// Test that it deletes pending plans. +func TestPendingPlanFinder_DeletePlans(t *testing.T) { + files := map[string]interface{}{ + "default": map[string]interface{}{ + "dir1": map[string]interface{}{ + "default.tfplan": nil, + }, + "dir2": map[string]interface{}{ + "default.tfplan": nil, + }, + }, + } + tmp, cleanup := DirStructure(t, + files) + defer cleanup() + + // Create a git repo in each workspace directory. + for dirname, contents := range files { + // If contents is nil then this isn't a directory. + if contents != nil { + runCmd(t, filepath.Join(tmp, dirname), "git", "init") + } + } + + pf := &events.DefaultPendingPlanFinder{} + Ok(t, pf.DeletePlans(tmp)) + + // First, check the files were deleted. + for _, plan := range []string{ + "default/dir1/default.tfplan", + "default/dir2/default.tfplan", + } { + absPath := filepath.Join(tmp, plan) + _, err := os.Stat(absPath) + ErrContains(t, "no such file or directory", err) + } + + // Double check by using Find(). + foundPlans, err := pf.Find(tmp) + Ok(t, err) + Equals(t, 0, len(foundPlans)) +} + func runCmd(t *testing.T, dir string, name string, args ...string) string { t.Helper() cpCmd := exec.Command(name, args...) diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index eeaec0a8c6..fb113ec5c7 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -50,7 +50,7 @@ type DefaultProjectCommandBuilder struct { WorkingDirLocker WorkingDirLocker AllowRepoConfig bool AllowRepoConfigFlag string - PendingPlanFinder *PendingPlanFinder + PendingPlanFinder *DefaultPendingPlanFinder CommentBuilder CommentBuilder } diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index 842a6567f8..572cfc625a 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -197,7 +197,7 @@ projects: VCSClient: vcsClient, ProjectFinder: &events.DefaultProjectFinder{}, AllowRepoConfig: true, - PendingPlanFinder: &events.PendingPlanFinder{}, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, AllowRepoConfigFlag: "allow-repo-config", CommentBuilder: &events.CommentParser{}, } @@ -809,7 +809,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { ProjectFinder: &events.DefaultProjectFinder{}, AllowRepoConfig: true, AllowRepoConfigFlag: "allow-repo-config", - PendingPlanFinder: &events.PendingPlanFinder{}, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, CommentBuilder: &events.CommentParser{}, } diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index cea477ddf8..f2ec230ef5 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -28,6 +28,16 @@ import ( "github.com/runatlantis/atlantis/server/logging" ) +// DirNotExistErr is an error caused by the directory not existing. +type DirNotExistErr struct { + RepoRelDir string +} + +// Error implements the error interface. +func (d DirNotExistErr) Error() string { + return fmt.Sprintf("dir %q does not exist", d.RepoRelDir) +} + //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_lock_url_generator.go LockURLGenerator // LockURLGenerator generates urls to locks. @@ -53,27 +63,15 @@ type WebhooksSender interface { Send(log *logging.SimpleLogger, res webhooks.ApplyResult) error } -// PlanSuccess is the result of a successful plan. -type PlanSuccess struct { - // TerraformOutput is the output from Terraform of running plan. - TerraformOutput string - // LockURL is the full URL to the lock held by this plan. - LockURL string - // RePlanCmd is the command that users should run to re-plan this project. - RePlanCmd string - // ApplyCmd is the command that users should run to apply this plan. - ApplyCmd string -} - //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_command_runner.go ProjectCommandRunner // ProjectCommandRunner runs project commands. A project command is a command // for a specific TF project. type ProjectCommandRunner interface { // Plan runs terraform plan for the project described by ctx. - Plan(ctx models.ProjectCommandContext) ProjectResult + Plan(ctx models.ProjectCommandContext) models.ProjectResult // Apply runs terraform apply for the project described by ctx. - Apply(ctx models.ProjectCommandContext) ProjectResult + Apply(ctx models.ProjectCommandContext) models.ProjectResult } // DefaultProjectCommandRunner implements ProjectCommandRunner. @@ -94,9 +92,9 @@ type DefaultProjectCommandRunner struct { } // Plan runs terraform plan for the project described by ctx. -func (p *DefaultProjectCommandRunner) Plan(ctx models.ProjectCommandContext) ProjectResult { +func (p *DefaultProjectCommandRunner) Plan(ctx models.ProjectCommandContext) models.ProjectResult { planSuccess, failure, err := p.doPlan(ctx) - return ProjectResult{ + return models.ProjectResult{ PlanSuccess: planSuccess, Error: err, Failure: failure, @@ -107,9 +105,9 @@ func (p *DefaultProjectCommandRunner) Plan(ctx models.ProjectCommandContext) Pro } // Apply runs terraform apply for the project described by ctx. -func (p *DefaultProjectCommandRunner) Apply(ctx models.ProjectCommandContext) ProjectResult { +func (p *DefaultProjectCommandRunner) Apply(ctx models.ProjectCommandContext) models.ProjectResult { applyOut, failure, err := p.doApply(ctx) - return ProjectResult{ + return models.ProjectResult{ Failure: failure, Error: err, ApplySuccess: applyOut, @@ -119,7 +117,7 @@ func (p *DefaultProjectCommandRunner) Apply(ctx models.ProjectCommandContext) Pr } } -func (p *DefaultProjectCommandRunner) doPlan(ctx models.ProjectCommandContext) (*PlanSuccess, string, error) { +func (p *DefaultProjectCommandRunner) doPlan(ctx models.ProjectCommandContext) (*models.PlanSuccess, string, error) { // Acquire Atlantis lock for this repo/dir/workspace. lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.BaseRepo.FullName, ctx.RepoRelDir)) if err != nil { @@ -146,6 +144,9 @@ func (p *DefaultProjectCommandRunner) doPlan(ctx models.ProjectCommandContext) ( return nil, "", cloneErr } projAbsPath := filepath.Join(repoDir, ctx.RepoRelDir) + if _, err := os.Stat(projAbsPath); os.IsNotExist(err) { + return nil, "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} + } // Use default stage unless another workflow is defined in config stage := p.defaultPlanStage() @@ -165,7 +166,7 @@ func (p *DefaultProjectCommandRunner) doPlan(ctx models.ProjectCommandContext) ( return nil, "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } - return &PlanSuccess{ + return &models.PlanSuccess{ LockURL: p.LockURLGenerator.GenerateLockURL(lockAttempt.LockKey), TerraformOutput: strings.Join(outputs, "\n"), RePlanCmd: ctx.RePlanCmd, @@ -208,6 +209,9 @@ func (p *DefaultProjectCommandRunner) doApply(ctx models.ProjectCommandContext) return "", "", err } absPath := filepath.Join(repoDir, ctx.RepoRelDir) + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return "", "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} + } // Figure out what our apply requirements are. var applyRequirements []string diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index 83ee4b27df..390f81ed21 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -147,7 +147,8 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { WorkingDirLocker: events.NewDefaultWorkingDirLocker(), } - repoDir := "/tmp/mydir" + repoDir, cleanup := TempDir(t) + defer cleanup() When(mockWorkingDir.Clone( matchers.AnyPtrToLoggingSimpleLogger(), matchers.AnyModelsRepo(), @@ -223,7 +224,9 @@ func TestDefaultProjectCommandRunner_ApplyNotApproved(t *testing.T) { RequireApprovalOverride: true, } ctx := models.ProjectCommandContext{} - When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn("/tmp/mydir", nil) + tmp, cleanup := TempDir(t) + defer cleanup() + When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn(tmp, nil) When(mockApproved.PullIsApproved(ctx.BaseRepo, ctx.Pull)).ThenReturn(false, nil) res := runner.Apply(ctx) @@ -241,7 +244,9 @@ func TestDefaultProjectCommandRunner_ApplyNotMergeable(t *testing.T) { RequireMergeableOverride: true, } ctx := models.ProjectCommandContext{} - When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn("/tmp/mydir", nil) + tmp, cleanup := TempDir(t) + defer cleanup() + When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn(tmp, nil) When(mockMergeable.PullIsMergeable(ctx.BaseRepo, ctx.Pull)).ThenReturn(false, nil) res := runner.Apply(ctx) @@ -422,8 +427,8 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) { Webhooks: mockSender, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), } - - repoDir := "/tmp/mydir" + repoDir, cleanup := TempDir(t) + defer cleanup() When(mockWorkingDir.GetWorkingDir( matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), diff --git a/server/events/project_result.go b/server/events/project_result.go deleted file mode 100644 index 730c75924d..0000000000 --- a/server/events/project_result.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2017 HootSuite Media Inc. -// -// Licensed under the Apache License, Version 2.0 (the License); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an AS IS BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// Modified hereafter by contributors to runatlantis/atlantis. - -package events - -import ( - "github.com/runatlantis/atlantis/server/events/models" -) - -// ProjectResult is the result of executing a plan/apply for a specific project. -type ProjectResult struct { - RepoRelDir string - Workspace string - Error error - Failure string - PlanSuccess *PlanSuccess - ApplySuccess string - ProjectName string -} - -// Status returns the vcs commit status of this project result. -func (p ProjectResult) Status() models.CommitStatus { - if p.Error != nil { - return models.FailedCommitStatus - } - if p.Failure != "" { - return models.FailedCommitStatus - } - return models.SuccessCommitStatus -} diff --git a/server/events/pull_closed_executor.go b/server/events/pull_closed_executor.go index 8792face08..aa40ebea15 100644 --- a/server/events/pull_closed_executor.go +++ b/server/events/pull_closed_executor.go @@ -16,10 +16,13 @@ package events import ( "bytes" "fmt" + "github.com/runatlantis/atlantis/server/events/db" "sort" "strings" "text/template" + "github.com/runatlantis/atlantis/server/logging" + "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/locking" "github.com/runatlantis/atlantis/server/events/models" @@ -41,6 +44,8 @@ type PullClosedExecutor struct { Locker locking.Locker VCSClient vcs.ClientProxy WorkingDir WorkingDir + Logger logging.SimpleLogging + DB *db.BoltDB } type templatedProject struct { @@ -67,6 +72,11 @@ func (p *PullClosedExecutor) CleanUpPull(repo models.Repo, pull models.PullReque return errors.Wrap(err, "cleaning up locks") } + // Delete pull from DB. + if err := p.DB.DeletePullStatus(pull); err != nil { + p.Logger.Err("deleting pull from db: %s", err) + } + // If there are no locks then there's no need to comment. if len(locks) == 0 { return nil diff --git a/server/events/pull_closed_executor_test.go b/server/events/pull_closed_executor_test.go index c1c353b0b1..5bd2a11128 100644 --- a/server/events/pull_closed_executor_test.go +++ b/server/events/pull_closed_executor_test.go @@ -15,6 +15,7 @@ package events_test import ( "errors" + "github.com/runatlantis/atlantis/server/events/db" "testing" . "github.com/petergtz/pegomock" @@ -62,13 +63,18 @@ func TestCleanUpPullNoLocks(t *testing.T) { w := mocks.NewMockWorkingDir() l := lockmocks.NewMockLocker() cp := vcsmocks.NewMockClientProxy() + tmp, cleanup := TempDir(t) + defer cleanup() + db, err := db.New(tmp) + Ok(t, err) pce := events.PullClosedExecutor{ Locker: l, VCSClient: cp, WorkingDir: w, + DB: db, } When(l.UnlockByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(nil, nil) - err := pce.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) + err = pce.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) Ok(t, err) cp.VerifyWasCalled(Never()).CreateComment(matchers.AnyModelsRepo(), AnyInt(), AnyString()) } @@ -139,21 +145,28 @@ func TestCleanUpPullComments(t *testing.T) { }, } for _, c := range cases { - w := mocks.NewMockWorkingDir() - cp := vcsmocks.NewMockClientProxy() - l := lockmocks.NewMockLocker() - pce := events.PullClosedExecutor{ - Locker: l, - VCSClient: cp, - WorkingDir: w, - } - t.Log("testing: " + c.Description) - When(l.UnlockByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(c.Locks, nil) - err := pce.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) - Ok(t, err) - _, _, comment := cp.VerifyWasCalledOnce().CreateComment(matchers.AnyModelsRepo(), AnyInt(), AnyString()).GetCapturedArguments() + func() { + w := mocks.NewMockWorkingDir() + cp := vcsmocks.NewMockClientProxy() + l := lockmocks.NewMockLocker() + tmp, cleanup := TempDir(t) + defer cleanup() + db, err := db.New(tmp) + Ok(t, err) + pce := events.PullClosedExecutor{ + Locker: l, + VCSClient: cp, + WorkingDir: w, + DB: db, + } + t.Log("testing: " + c.Description) + When(l.UnlockByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(c.Locks, nil) + err = pce.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) + Ok(t, err) + _, _, comment := cp.VerifyWasCalledOnce().CreateComment(matchers.AnyModelsRepo(), AnyInt(), AnyString()).GetCapturedArguments() - expected := "Locks and plans deleted for the projects and workspaces modified in this pull request:\n\n" + c.Exp - Equals(t, expected, comment) + expected := "Locks and plans deleted for the projects and workspaces modified in this pull request:\n\n" + c.Exp + Equals(t, expected, comment) + }() } } diff --git a/server/events/vcs/bitbucketcloud/client.go b/server/events/vcs/bitbucketcloud/client.go index 4f5a7fedd6..080b87979f 100644 --- a/server/events/vcs/bitbucketcloud/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -14,7 +14,7 @@ import ( ) type Client struct { - HttpClient *http.Client + HTTPClient *http.Client Username string Password string BaseURL string @@ -30,7 +30,7 @@ func NewClient(httpClient *http.Client, username string, password string, atlant httpClient = http.DefaultClient } return &Client{ - HttpClient: httpClient, + HTTPClient: httpClient, Username: username, Password: password, BaseURL: BaseURL, @@ -172,6 +172,13 @@ func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status return err } +// MergePull merges the pull request. +func (b *Client) MergePull(pull models.PullRequest) error { + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/merge", b.BaseURL, pull.BaseRepo.FullName, pull.Num) + _, err := b.makeRequest("POST", path, nil) + return err +} + // prepRequest adds the HTTP basic auth. func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, path, body) @@ -190,7 +197,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b if reqBody != nil { req.Header.Add("Content-Type", "application/json") } - resp, err := b.HttpClient.Do(req) + resp, err := b.HTTPClient.Do(req) if err != nil { return nil, err } diff --git a/server/events/vcs/bitbucketcloud/client_test.go b/server/events/vcs/bitbucketcloud/client_test.go index 39e2aaeab2..d9fda3f5a5 100644 --- a/server/events/vcs/bitbucketcloud/client_test.go +++ b/server/events/vcs/bitbucketcloud/client_test.go @@ -187,7 +187,7 @@ func TestClient_PullIsApproved(t *testing.T) { switch r.RequestURI { // The first request should hit this URL. case "/2.0/repositories/owner/repo/pullrequests/1": - w.Write([]byte(json)) // nolint: errcheck + w.Write(json) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go index fae60505ac..da2439198e 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -23,7 +23,7 @@ import ( const maxCommentLength = 32768 type Client struct { - HttpClient *http.Client + HTTPClient *http.Client Username string Password string BaseURL string @@ -52,7 +52,7 @@ func NewClient(httpClient *http.Client, username string, password string, baseUR } urlWithoutPath := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) return &Client{ - HttpClient: httpClient, + HTTPClient: httpClient, Username: username, Password: password, BaseURL: urlWithoutPath, @@ -235,6 +235,17 @@ func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status return err } +// MergePull merges the pull request. +func (b *Client) MergePull(pull models.PullRequest) error { + projectKey, err := b.GetProjectKey(pull.BaseRepo.Name, pull.BaseRepo.SanitizedCloneURL) + if err != nil { + return err + } + path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/merge", b.BaseURL, projectKey, pull.BaseRepo.Name, pull.Num) + _, err = b.makeRequest("POST", path, nil) + return err +} + // prepRequest adds the HTTP basic auth. func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, path, body) @@ -253,7 +264,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b if reqBody != nil { req.Header.Add("Content-Type", "application/json") } - resp, err := b.HttpClient.Do(req) + resp, err := b.HTTPClient.Do(req) if err != nil { return nil, err } diff --git a/server/events/vcs/bitbucketserver/request_validation.go b/server/events/vcs/bitbucketserver/request_validation.go index dd2938a364..c6cea0ba05 100644 --- a/server/events/vcs/bitbucketserver/request_validation.go +++ b/server/events/vcs/bitbucketserver/request_validation.go @@ -30,6 +30,7 @@ func ValidateSignature(payload []byte, signature string, secretKey []byte) error // and hashFunc. func genMAC(message, key []byte, hashFunc func() hash.Hash) []byte { mac := hmac.New(hashFunc, key) + // nolint: errcheck mac.Write(message) return mac.Sum(nil) } diff --git a/server/events/vcs/client.go b/server/events/vcs/client.go index d79bc287ed..750e625b4f 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -26,4 +26,5 @@ type Client interface { PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error + MergePull(pull models.PullRequest) error } diff --git a/server/events/vcs/common/comment_splitter.go b/server/events/vcs/common/common.go similarity index 74% rename from server/events/vcs/common/comment_splitter.go rename to server/events/vcs/common/common.go index 170f3e8049..7a41b1d835 100644 --- a/server/events/vcs/common/comment_splitter.go +++ b/server/events/vcs/common/common.go @@ -1,9 +1,15 @@ +// Package common is used to share common code between all VCS clients without +// running into circular dependency issues. package common import ( "math" ) +// AutomergeCommitMsg is the commit message Atlantis will use when automatically +// merging pull requests. +const AutomergeCommitMsg = "[Atlantis] Automatically merging after successful apply" + // SplitComment splits comment into a slice of comments that are under maxSize. // It appends sepEnd to all comments that have a following comment. // It prepends sepStart to all comments that have a preceding comment. diff --git a/server/events/vcs/common/comment_splitter_test.go b/server/events/vcs/common/common_test.go similarity index 100% rename from server/events/vcs/common/comment_splitter_test.go rename to server/events/vcs/common/common_test.go diff --git a/server/events/vcs/github_client.go b/server/events/vcs/github_client.go index 5c63490db9..15fbc1b691 100644 --- a/server/events/vcs/github_client.go +++ b/server/events/vcs/github_client.go @@ -175,3 +175,21 @@ func (g *GithubClient) UpdateStatus(repo models.Repo, pull models.PullRequest, s _, _, err := g.client.Repositories.CreateStatus(g.ctx, repo.Owner, repo.Name, pull.HeadCommit, status) return err } + +// MergePull merges the pull request. +func (g *GithubClient) MergePull(pull models.PullRequest) error { + mergeResult, _, err := g.client.PullRequests.Merge( + g.ctx, + pull.BaseRepo.Owner, + pull.BaseRepo.Name, + pull.Num, + common.AutomergeCommitMsg, + nil) + if err != nil { + return errors.Wrap(err, "merging pull request") + } + if !mergeResult.GetMerged() { + return fmt.Errorf("could not merge pull request: %s", mergeResult.GetMessage()) + } + return nil +} diff --git a/server/events/vcs/github_client_test.go b/server/events/vcs/github_client_test.go index f0cbb5c1c6..86477afa44 100644 --- a/server/events/vcs/github_client_test.go +++ b/server/events/vcs/github_client_test.go @@ -44,7 +44,7 @@ func TestGithubClient_GetModifiedFiles(t *testing.T) { ; rel="last"`) w.Write([]byte(firstResp)) // nolint: errcheck return - // The second should hit this URL. + // The second should hit this URL. case "/api/v3/repos/owner/repo/pulls/1/files?page=2&per_page=300": w.Write([]byte(secondResp)) // nolint: errcheck default: @@ -287,10 +287,93 @@ func TestGithubClient_PullIsMergeable(t *testing.T) { } } +func TestGithubClient_MergePull(t *testing.T) { + cases := []struct { + code int + message string + merged string + expErr string + }{ + { + code: 200, + message: "Pull Request successfully merged", + merged: "true", + expErr: "", + }, + { + code: 405, + message: "Pull Request is not mergeable", + expErr: "405 Pull Request is not mergeable []", + }, + { + code: 409, + message: "Head branch was modified. Review and try the merge again.", + expErr: "409 Head branch was modified. Review and try the merge again. []", + }, + } + + for _, c := range cases { + t.Run(c.message, func(t *testing.T) { + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/api/v3/repos/owner/repo/pulls/1/merge": + body, err := ioutil.ReadAll(r.Body) + Ok(t, err) + exp := "{\"commit_message\":\"[Atlantis] Automatically merging after successful apply\"}\n" + Equals(t, exp, string(body)) + var resp string + if c.code == 200 { + resp = fmt.Sprintf(`{"message":"%s","merged":%s}%s`, c.message, c.merged, "\n") + } else { + resp = fmt.Sprintf(`{"message":"%s"}%s`, c.message, "\n") + } + defer r.Body.Close() // nolint: errcheck + w.WriteHeader(c.code) + w.Write([]byte(resp)) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := vcs.NewGithubClient(testServerURL.Host, "user", "pass") + Ok(t, err) + defer disableSSLVerification()() + + err = client.MergePull( + models.PullRequest{ + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.Github, + Hostname: "github.com", + }, + }, + Num: 1, + }) + + if c.expErr == "" { + Ok(t, err) + } else { + ErrContains(t, c.expErr, err) + } + }) + } +} + // disableSSLVerification disables ssl verification for the global http client // and returns a function to be called in a defer that will re-enable it. func disableSSLVerification() func() { orig := http.DefaultTransport.(*http.Transport).TLSClientConfig + // nolint: gosec http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} return func() { http.DefaultTransport.(*http.Transport).TLSClientConfig = orig diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go index 269d826a3f..c3f8c6bd56 100644 --- a/server/events/vcs/gitlab_client.go +++ b/server/events/vcs/gitlab_client.go @@ -19,6 +19,8 @@ import ( "net/url" "strings" + "github.com/runatlantis/atlantis/server/events/vcs/common" + "github.com/hashicorp/go-version" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/logging" @@ -199,6 +201,18 @@ func (g *GitlabClient) GetMergeRequest(repoFullName string, pullNum int) (*gitla return mr, err } +// MergePull merges the merge request. +func (g *GitlabClient) MergePull(pull models.PullRequest) error { + commitMsg := common.AutomergeCommitMsg + _, _, err := g.Client.MergeRequests.AcceptMergeRequest( + pull.BaseRepo.FullName, + pull.Num, + &gitlab.AcceptMergeRequestOptions{ + MergeCommitMessage: &commitMsg, + }) + return errors.Wrap(err, "unable to merge merge request, it may not be in a mergeable state") +} + // GetVersion returns the version of the Gitlab server this client is using. func (g *GitlabClient) GetVersion() (*version.Version, error) { req, err := g.Client.NewRequest("GET", "/version", nil, nil) diff --git a/server/events/vcs/gitlab_client_test.go b/server/events/vcs/gitlab_client_test.go index 5843c5c76a..af38c4a743 100644 --- a/server/events/vcs/gitlab_client_test.go +++ b/server/events/vcs/gitlab_client_test.go @@ -1,8 +1,13 @@ package vcs import ( + "net/http" + "net/http/httptest" "testing" + "github.com/lkysow/go-gitlab" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/hashicorp/go-version" . "github.com/runatlantis/atlantis/testing" ) @@ -95,3 +100,72 @@ func TestGitlabClient_SupportsCommonMark(t *testing.T) { }) } } + +func TestGitlabClient_MergePull(t *testing.T) { + cases := []struct { + description string + glResponse string + code int + expErr string + }{ + { + "success", + mergeSuccess, + 200, + "", + }, + { + "405", + `{"message":"405 Method Not Allowed"}`, + 405, + "405 {message: 405 Method Not Allowed}", + }, + { + "406", + `{"message":"406 Branch cannot be merged"}`, + 406, + "406 {message: 406 Branch cannot be merged}", + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + testServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1/merge": + w.WriteHeader(c.code) + w.Write([]byte(c.glResponse)) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + } + })) + + internalClient := gitlab.NewClient(nil, "token") + Ok(t, internalClient.SetBaseURL(testServer.URL)) + client := &GitlabClient{ + Client: internalClient, + Version: nil, + } + + err := client.MergePull(models.PullRequest{ + Num: 1, + BaseRepo: models.Repo{ + FullName: "runatlantis/atlantis", + Owner: "runatlantis", + Name: "atlantis", + }, + }) + if c.expErr == "" { + Ok(t, err) + } else { + ErrContains(t, c.expErr, err) + ErrContains(t, "unable to merge merge request, it may not be in a mergeable state", err) + } + }) + } +} + +var mergeSuccess = `{"id":22461274,"iid":13,"project_id":4580910,"title":"Update main.tf","description":"","state":"merged","created_at":"2019-01-15T18:27:29.375Z","updated_at":"2019-01-25T17:28:01.437Z","merged_by":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://gitlab.com/lkysow"},"merged_at":"2019-01-25T17:28:01.459Z","closed_by":null,"closed_at":null,"target_branch":"patch-1","source_branch":"patch-1-merger","upvotes":0,"downvotes":0,"author":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://gitlab.com/lkysow"},"assignee":null,"source_project_id":4580910,"target_project_id":4580910,"labels":[],"work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha":"c9b336f1c71d3e64810b8cfa2abcfab232d6bff6","user_notes_count":0,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":false,"web_url":"https://gitlab.com/lkysow/atlantis-example/merge_requests/13","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"subscribed":true,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"diff_refs":{"base_sha":"67cb91d3f6198189f433c045154a885784ba6977","head_sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha":"67cb91d3f6198189f433c045154a885784ba6977"},"merge_error":null,"approvals_before_merge":null}` diff --git a/server/events/vcs/mocks/mock_proxy.go b/server/events/vcs/mocks/mock_proxy.go index 69c5d98511..8c36c4dd5b 100644 --- a/server/events/vcs/mocks/mock_proxy.go +++ b/server/events/vcs/mocks/mock_proxy.go @@ -105,6 +105,21 @@ func (mock *MockClientProxy) UpdateStatus(repo models.Repo, pull models.PullRequ return ret0 } +func (mock *MockClientProxy) MergePull(pull models.PullRequest) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockClientProxy().") + } + params := []pegomock.Param{pull} + result := pegomock.GetGenericMockFrom(mock).Invoke("MergePull", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + func (mock *MockClientProxy) VerifyWasCalledOnce() *VerifierClientProxy { return &VerifierClientProxy{ mock: mock, @@ -308,3 +323,30 @@ func (c *ClientProxy_UpdateStatus_OngoingVerification) GetAllCapturedArguments() } return } + +func (verifier *VerifierClientProxy) MergePull(pull models.PullRequest) *ClientProxy_MergePull_OngoingVerification { + params := []pegomock.Param{pull} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MergePull", params, verifier.timeout) + return &ClientProxy_MergePull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type ClientProxy_MergePull_OngoingVerification struct { + mock *MockClientProxy + methodInvocations []pegomock.MethodInvocation +} + +func (c *ClientProxy_MergePull_OngoingVerification) GetCapturedArguments() models.PullRequest { + pull := c.GetAllCapturedArguments() + return pull[len(pull)-1] +} + +func (c *ClientProxy_MergePull_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.PullRequest, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(models.PullRequest) + } + } + return +} diff --git a/server/events/vcs/not_configured_vcs_client.go b/server/events/vcs/not_configured_vcs_client.go index 3cb39461ab..2b43973b94 100644 --- a/server/events/vcs/not_configured_vcs_client.go +++ b/server/events/vcs/not_configured_vcs_client.go @@ -41,6 +41,9 @@ func (a *NotConfiguredVCSClient) PullIsMergeable(repo models.Repo, pull models.P func (a *NotConfiguredVCSClient) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error { return a.err() } +func (a *NotConfiguredVCSClient) MergePull(pull models.PullRequest) error { + return a.err() +} func (a *NotConfiguredVCSClient) err() error { return fmt.Errorf("atlantis was not configured to support repos from %s", a.Host.String()) } diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index 91ec6f22c0..b4c2e1f914 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -27,6 +27,7 @@ type ClientProxy interface { PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error + MergePull(pull models.PullRequest) error } // DefaultClientProxy proxies calls to the correct VCS client depending on which @@ -79,3 +80,7 @@ func (d *DefaultClientProxy) PullIsMergeable(repo models.Repo, pull models.PullR func (d *DefaultClientProxy) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error { return d.clients[repo.VCSHost.Type].UpdateStatus(repo, pull, state, description) } + +func (d *DefaultClientProxy) MergePull(pull models.PullRequest) error { + return d.clients[pull.BaseRepo.VCSHost.Type].MergePull(pull) +} diff --git a/server/events/working_dir.go b/server/events/working_dir.go index 9177532aaf..cf897471cf 100644 --- a/server/events/working_dir.go +++ b/server/events/working_dir.go @@ -177,7 +177,7 @@ func (w *FileWorkspace) forceClone(log *logging.SimpleLogger, if err != nil { return "", errors.Wrapf(err, "running %s: %s", cmdStr, string(output)) } - log.Debug("ran: %s. Output: %s", cmdStr, string(output)) + log.Debug("ran: %s. Output: %s", cmdStr, strings.TrimSuffix(string(output), "\n")) } return cloneDir, nil } diff --git a/server/events/yaml/raw/config.go b/server/events/yaml/raw/config.go index 218e02e608..a3213e218e 100644 --- a/server/events/yaml/raw/config.go +++ b/server/events/yaml/raw/config.go @@ -7,11 +7,15 @@ import ( "github.com/runatlantis/atlantis/server/events/yaml/valid" ) +// DefaultAutomerge is the default setting for automerge. +const DefaultAutomerge = false + // Config is the representation for the whole config file at the top level. type Config struct { Version *int `yaml:"version,omitempty"` Projects []Project `yaml:"projects,omitempty"` Workflows map[string]Workflow `yaml:"workflows,omitempty"` + Automerge *bool `yaml:"automerge,omitempty"` } func (c Config) Validate() error { @@ -42,9 +46,16 @@ func (c Config) ToValid() valid.Config { for k, v := range c.Workflows { validWorkflows[k] = v.ToValid() } + + automerge := DefaultAutomerge + if c.Automerge != nil { + automerge = *c.Automerge + } + return valid.Config{ Version: *c.Version, Projects: validProjects, Workflows: validWorkflows, + Automerge: automerge, } } diff --git a/server/events/yaml/raw/config_test.go b/server/events/yaml/raw/config_test.go index 73d6eaf543..9aedc84da5 100644 --- a/server/events/yaml/raw/config_test.go +++ b/server/events/yaml/raw/config_test.go @@ -92,10 +92,21 @@ func TestConfig_UnmarshalYAML(t *testing.T) { }, expErr: "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `value` into []raw.Project", }, + { + description: "automerge not a boolean", + input: "version: 2\nautomerge: notabool", + exp: raw.Config{ + Version: nil, + Projects: nil, + Workflows: nil, + }, + expErr: "yaml: unmarshal errors:\n line 2: cannot unmarshal !!str `notabool` into bool", + }, { description: "should use values if set", input: ` version: 2 +automerge: true projects: - dir: mydir workspace: myworkspace @@ -112,7 +123,8 @@ workflows: apply: steps: []`, exp: raw.Config{ - Version: Int(2), + Version: Int(2), + Automerge: Bool(true), Projects: []raw.Project{ { Dir: String("mydir"), @@ -215,9 +227,45 @@ func TestConfig_ToValid(t *testing.T) { }, }, { - description: "everything set", + description: "automerge ommitted", input: raw.Config{ Version: Int(2), + }, + exp: valid.Config{ + Version: 2, + Automerge: false, + Workflows: map[string]valid.Workflow{}, + }, + }, + { + description: "automerge true", + input: raw.Config{ + Version: Int(2), + Automerge: Bool(true), + }, + exp: valid.Config{ + Version: 2, + Automerge: true, + Workflows: map[string]valid.Workflow{}, + }, + }, + { + description: "automerge false", + input: raw.Config{ + Version: Int(2), + Automerge: Bool(false), + }, + exp: valid.Config{ + Version: 2, + Automerge: false, + Workflows: map[string]valid.Workflow{}, + }, + }, + { + description: "everything set", + input: raw.Config{ + Version: Int(2), + Automerge: Bool(true), Workflows: map[string]raw.Workflow{ "myworkflow": { Apply: &raw.Stage{ @@ -243,7 +291,8 @@ func TestConfig_ToValid(t *testing.T) { }, }, exp: valid.Config{ - Version: 2, + Version: 2, + Automerge: true, Workflows: map[string]valid.Workflow{ "myworkflow": { Apply: &valid.Stage{ diff --git a/server/events/yaml/valid/valid.go b/server/events/yaml/valid/valid.go index cdb6ee2a2a..34958a4e77 100644 --- a/server/events/yaml/valid/valid.go +++ b/server/events/yaml/valid/valid.go @@ -11,6 +11,7 @@ type Config struct { Version int Projects []Project Workflows map[string]Workflow + Automerge bool } func (c Config) GetPlanStage(workflowName string) *Stage { diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index 9c98e89d1a..60bd3b3c34 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -3,6 +3,7 @@ package server_test import ( "bytes" "fmt" + "github.com/runatlantis/atlantis/server/events/db" "io/ioutil" "net/http" "net/http/httptest" @@ -18,7 +19,6 @@ import ( "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/locking" - "github.com/runatlantis/atlantis/server/events/locking/boltdb" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/mocks/matchers" "github.com/runatlantis/atlantis/server/events/models" @@ -38,158 +38,251 @@ func TestGitHubWorkflow(t *testing.T) { cases := []struct { Description string // RepoDir is relative to testfixtures/test-repos. - RepoDir string - ModifiedFiles []string - ExpAutoplanCommentFile string - ExpMergeCommentFile string - CommentAndReplies []string + RepoDir string + // ModifiedFiles are the list of files that have been modified in this + // pull request. + ModifiedFiles []string + // Comments are what our mock user writes to the pull request. + Comments []string + // ExpAutomerge is true if we expect Atlantis to automerge. + ExpAutomerge bool + // ExpAutoplan is true if we expect Atlantis to autoplan. + ExpAutoplan bool + // ExpReplies is a list of files containing the expected replies that + // Atlantis writes to the pull request in order. + ExpReplies []string }{ { - Description: "simple", - RepoDir: "simple", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis apply", "exp-output-apply.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "simple", + RepoDir: "simple", + ModifiedFiles: []string{"main.tf"}, + Comments: []string{ + "atlantis apply", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-apply.txt", + "exp-output-merge.txt", + }, + ExpAutoplan: true, }, { - Description: "simple with plan comment", - RepoDir: "simple", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis plan", "exp-output-autoplan.txt", - "atlantis apply", "exp-output-apply.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "simple with plan comment", + RepoDir: "simple", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis plan", + "atlantis apply", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-autoplan.txt", + "exp-output-apply.txt", + "exp-output-merge.txt", + }, }, { - Description: "simple with comment -var", - RepoDir: "simple", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis plan -- -var var=overridden", "exp-output-atlantis-plan-var-overridden.txt", - "atlantis apply", "exp-output-apply-var.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "simple with comment -var", + RepoDir: "simple", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis plan -- -var var=overridden", + "atlantis apply", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-atlantis-plan-var-overridden.txt", + "exp-output-apply-var.txt", + "exp-output-merge.txt", + }, }, { - Description: "simple with workspaces", - RepoDir: "simple", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis plan -- -var var=default_workspace", "exp-output-atlantis-plan.txt", - "atlantis plan -w new_workspace -- -var var=new_workspace", "exp-output-atlantis-plan-new-workspace.txt", - "atlantis apply -w default", "exp-output-apply-var-default-workspace.txt", - "atlantis apply -w new_workspace", "exp-output-apply-var-new-workspace.txt", - }, - ExpMergeCommentFile: "exp-output-merge-workspaces.txt", + Description: "simple with workspaces", + RepoDir: "simple", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis plan -- -var var=default_workspace", + "atlantis plan -w new_workspace -- -var var=new_workspace", + "atlantis apply -w default", + "atlantis apply -w new_workspace", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-atlantis-plan.txt", + "exp-output-atlantis-plan-new-workspace.txt", + "exp-output-apply-var-default-workspace.txt", + "exp-output-apply-var-new-workspace.txt", + "exp-output-merge-workspaces.txt", + }, }, { - Description: "simple with workspaces and apply all", - RepoDir: "simple", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis plan -- -var var=default_workspace", "exp-output-atlantis-plan.txt", - "atlantis plan -w new_workspace -- -var var=new_workspace", "exp-output-atlantis-plan-new-workspace.txt", - "atlantis apply", "exp-output-apply-var-all.txt", - }, - ExpMergeCommentFile: "exp-output-merge-workspaces.txt", + Description: "simple with workspaces and apply all", + RepoDir: "simple", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis plan -- -var var=default_workspace", + "atlantis plan -w new_workspace -- -var var=new_workspace", + "atlantis apply", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-atlantis-plan.txt", + "exp-output-atlantis-plan-new-workspace.txt", + "exp-output-apply-var-all.txt", + "exp-output-merge-workspaces.txt", + }, }, { - Description: "simple with atlantis.yaml", - RepoDir: "simple-yaml", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis apply -w staging", "exp-output-apply-staging.txt", - "atlantis apply -w default", "exp-output-apply-default.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "simple with atlantis.yaml", + RepoDir: "simple-yaml", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply -w staging", + "atlantis apply -w default", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-apply-staging.txt", + "exp-output-apply-default.txt", + "exp-output-merge.txt", + }, }, { - Description: "simple with atlantis.yaml and apply all", - RepoDir: "simple-yaml", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis apply", "exp-output-apply-all.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "simple with atlantis.yaml and apply all", + RepoDir: "simple-yaml", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-apply-all.txt", + "exp-output-merge.txt", + }, }, { - Description: "simple with atlantis.yaml and plan/apply all", - RepoDir: "simple-yaml", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis plan", "exp-output-autoplan.txt", - "atlantis apply", "exp-output-apply-all.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "simple with atlantis.yaml and plan/apply all", + RepoDir: "simple-yaml", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis plan", + "atlantis apply", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-autoplan.txt", + "exp-output-apply-all.txt", + "exp-output-merge.txt", + }, }, { - Description: "modules staging only", - RepoDir: "modules", - ModifiedFiles: []string{"staging/main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan-only-staging.txt", - CommentAndReplies: []string{ - "atlantis apply -d staging", "exp-output-apply-staging.txt", - }, - ExpMergeCommentFile: "exp-output-merge-only-staging.txt", + Description: "modules staging only", + RepoDir: "modules", + ModifiedFiles: []string{"staging/main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply -d staging", + }, + ExpReplies: []string{ + "exp-output-autoplan-only-staging.txt", + "exp-output-apply-staging.txt", + "exp-output-merge-only-staging.txt", + }, }, { - Description: "modules modules only", - RepoDir: "modules", - ModifiedFiles: []string{"modules/null/main.tf"}, - ExpAutoplanCommentFile: "", - CommentAndReplies: []string{ - "atlantis plan -d staging", "exp-output-plan-staging.txt", - "atlantis plan -d production", "exp-output-plan-production.txt", - "atlantis apply -d staging", "exp-output-apply-staging.txt", - "atlantis apply -d production", "exp-output-apply-production.txt", - }, - ExpMergeCommentFile: "exp-output-merge-all-dirs.txt", + Description: "modules modules only", + RepoDir: "modules", + ModifiedFiles: []string{"modules/null/main.tf"}, + ExpAutoplan: false, + Comments: []string{ + "atlantis plan -d staging", + "atlantis plan -d production", + "atlantis apply -d staging", + "atlantis apply -d production", + }, + ExpReplies: []string{ + "exp-output-plan-staging.txt", + "exp-output-plan-production.txt", + "exp-output-apply-staging.txt", + "exp-output-apply-production.txt", + "exp-output-merge-all-dirs.txt", + }, }, { - Description: "modules-yaml", - RepoDir: "modules-yaml", - ModifiedFiles: []string{"modules/null/main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis apply -d staging", "exp-output-apply-staging.txt", - "atlantis apply -d production", "exp-output-apply-production.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "modules-yaml", + RepoDir: "modules-yaml", + ModifiedFiles: []string{"modules/null/main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply -d staging", + "atlantis apply -d production", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-apply-staging.txt", + "exp-output-apply-production.txt", + "exp-output-merge.txt", + }, + }, + { + Description: "tfvars-yaml", + RepoDir: "tfvars-yaml", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply -p staging", + "atlantis apply -p default", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-apply-staging.txt", + "exp-output-apply-default.txt", + "exp-output-merge.txt", + }, }, { - Description: "tfvars-yaml", - RepoDir: "tfvars-yaml", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "exp-output-autoplan.txt", - CommentAndReplies: []string{ - "atlantis apply -p staging", "exp-output-apply-staging.txt", - "atlantis apply -p default", "exp-output-apply-default.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "tfvars no autoplan", + RepoDir: "tfvars-yaml-no-autoplan", + ModifiedFiles: []string{"main.tf"}, + ExpAutoplan: false, + Comments: []string{ + "atlantis plan -p staging", + "atlantis plan -p default", + "atlantis apply -p staging", + "atlantis apply -p default", + }, + ExpReplies: []string{ + "exp-output-plan-staging.txt", + "exp-output-plan-default.txt", + "exp-output-apply-staging.txt", + "exp-output-apply-default.txt", + "exp-output-merge.txt", + }, }, { - Description: "tfvars no autoplan", - RepoDir: "tfvars-yaml-no-autoplan", - ModifiedFiles: []string{"main.tf"}, - ExpAutoplanCommentFile: "", - CommentAndReplies: []string{ - "atlantis plan -p staging", "exp-output-plan-staging.txt", - "atlantis plan -p default", "exp-output-plan-default.txt", - "atlantis apply -p staging", "exp-output-apply-staging.txt", - "atlantis apply -p default", "exp-output-apply-default.txt", - }, - ExpMergeCommentFile: "exp-output-merge.txt", + Description: "automerge", + RepoDir: "automerge", + ExpAutomerge: true, + ExpAutoplan: true, + ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"}, + Comments: []string{ + "atlantis apply -d dir1", + "atlantis apply -d dir2", + }, + ExpReplies: []string{ + "exp-output-autoplan.txt", + "exp-output-apply-dir1.txt", + "exp-output-apply-dir2.txt", + "exp-output-automerge.txt", + "exp-output-merge.txt", + }, }, } for _, c := range cases { @@ -206,44 +299,51 @@ func TestGitHubWorkflow(t *testing.T) { w := httptest.NewRecorder() When(githubGetter.GetPullRequest(AnyRepo(), AnyInt())).ThenReturn(GitHubPullRequestParsed(headSHA), nil) When(vcsClient.GetModifiedFiles(AnyRepo(), matchers.AnyModelsPullRequest())).ThenReturn(c.ModifiedFiles, nil) - expNumTimesCalledCreateComment := 0 - // First, send the open pull request event and trigger an autoplan. + // First, send the open pull request event which triggers autoplan. pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA) ctrl.Post(w, pullOpenedReq) responseContains(t, w, 200, "Processing...") - if c.ExpAutoplanCommentFile != "" { - expNumTimesCalledCreateComment++ - _, _, autoplanComment := vcsClient.VerifyWasCalledOnce().CreateComment(AnyRepo(), AnyInt(), AnyString()).GetCapturedArguments() - assertCommentEquals(t, c.ExpAutoplanCommentFile, autoplanComment, c.RepoDir) - } // Now send any other comments. - for i := 0; i < len(c.CommentAndReplies); i += 2 { - comment := c.CommentAndReplies[i] - expOutputFile := c.CommentAndReplies[i+1] - + for _, comment := range c.Comments { commentReq := GitHubCommentEvent(t, comment) w = httptest.NewRecorder() ctrl.Post(w, commentReq) responseContains(t, w, 200, "Processing...") - // Each comment warrants a response. The comments are at the - // even indices. - if i%2 == 0 { - expNumTimesCalledCreateComment++ - } - _, _, atlantisComment := vcsClient.VerifyWasCalled(Times(expNumTimesCalledCreateComment)).CreateComment(AnyRepo(), AnyInt(), AnyString()).GetCapturedArguments() - assertCommentEquals(t, expOutputFile, atlantisComment, c.RepoDir) } - // Finally, send the pull request merged event. + // Send the "pull closed" event which would be triggered by the + // automerge or a manual merge. pullClosedReq := GitHubPullRequestClosedEvent(t) w = httptest.NewRecorder() ctrl.Post(w, pullClosedReq) responseContains(t, w, 200, "Pull request cleaned successfully") - expNumTimesCalledCreateComment++ - _, _, pullClosedComment := vcsClient.VerifyWasCalled(Times(expNumTimesCalledCreateComment)).CreateComment(AnyRepo(), AnyInt(), AnyString()).GetCapturedArguments() - assertCommentEquals(t, c.ExpMergeCommentFile, pullClosedComment, c.RepoDir) + + // Now we're ready to verify Atlantis made all the comments back + // (or replies) that we expect. + // We expect replies for each comment plus one for the locks deleted + // at the end. + expNumReplies := len(c.Comments) + 1 + if c.ExpAutoplan { + expNumReplies++ + } + if c.ExpAutomerge { + expNumReplies++ + } + + _, _, actReplies := vcsClient.VerifyWasCalled(Times(expNumReplies)).CreateComment(AnyRepo(), AnyInt(), AnyString()).GetAllCapturedArguments() + Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies)) + for i, expReply := range c.ExpReplies { + assertCommentEquals(t, expReply, actReplies[i], c.RepoDir) + } + + if c.ExpAutomerge { + // Verify that the merge API call was made. + vcsClient.VerifyWasCalledOnce().MergePull(matchers.AnyModelsPullRequest()) + } else { + vcsClient.VerifyWasCalled(Never()).MergePull(matchers.AnyModelsPullRequest()) + } }) } } @@ -275,7 +375,7 @@ func setupE2E(t *testing.T) (server.EventsController, *vcsmocks.MockClientProxy, } terraformClient, err := terraform.NewClient(dataDir, "") Ok(t, err) - boltdb, err := boltdb.New(dataDir) + boltdb, err := db.New(dataDir) Ok(t, err) lockingClient := locking.NewClient(boltdb) projectLocker := &events.DefaultProjectLocker{ @@ -328,9 +428,13 @@ func setupE2E(t *testing.T) (server.EventsController, *vcsmocks.MockClientProxy, WorkingDirLocker: locker, AllowRepoConfigFlag: "allow-repo-config", AllowRepoConfig: true, - PendingPlanFinder: &events.PendingPlanFinder{}, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, CommentBuilder: commentParser, }, + DB: boltdb, + PendingPlanFinder: &events.DefaultPendingPlanFinder{}, + GlobalAutomerge: false, + WorkingDir: workingDir, } repoWhitelistChecker, err := events.NewRepoWhitelistChecker("*") @@ -343,6 +447,7 @@ func setupE2E(t *testing.T) (server.EventsController, *vcsmocks.MockClientProxy, Locker: lockingClient, VCSClient: e2eVCSClient, WorkingDir: workingDir, + DB: boltdb, }, Logger: logger, Parser: eventParser, @@ -415,7 +520,7 @@ func GitHubPullRequestParsed(headSHA string) *github.PullRequest { Head: &github.PullRequestBranch{ Repo: &github.Repository{ FullName: github.String("runatlantis/atlantis-tests"), - CloneURL: github.String("/runatlantis/atlantis-tests.git"), + CloneURL: github.String("https://github.com/runatlantis/atlantis-tests.git"), }, SHA: github.String(headSHA), Ref: github.String("branch"), @@ -423,7 +528,7 @@ func GitHubPullRequestParsed(headSHA string) *github.PullRequest { Base: &github.PullRequestBranch{ Repo: &github.Repository{ FullName: github.String("runatlantis/atlantis-tests"), - CloneURL: github.String("/runatlantis/atlantis-tests.git"), + CloneURL: github.String("https://github.com/runatlantis/atlantis-tests.git"), }, Ref: github.String("master"), }, @@ -493,11 +598,19 @@ func assertCommentEquals(t *testing.T, expFile string, act string, repoDir strin resourceRegex := regexp.MustCompile(`null_resource\.simple\d?:.*`) act = resourceRegex.ReplaceAllString(act, "null_resource.simple:") - if string(exp) != act { + expStr := string(exp) + // My editor adds a newline to all the files, so if the actual comment + // doesn't end with a newline then strip the last newline from the file's + // contents. + if !strings.HasSuffix(act, "\n") { + expStr = strings.TrimSuffix(expStr, "\n") + } + + if expStr != act { // If in CI, we write the diff to the console. Otherwise we write the diff // to file so we can use our local diff viewer. if os.Getenv("CI") == "true" { - t.Logf("exp: %s, got: %s", string(exp), act) + t.Logf("exp: %s, got: %s", expStr, act) t.FailNow() } else { actFile := filepath.Join(absRepoPath(t, repoDir), expFile+".act") diff --git a/server/locks_controller.go b/server/locks_controller.go index 9c69abfa5a..d181554d42 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "github.com/runatlantis/atlantis/server/events/db" "net/http" "net/url" @@ -23,6 +24,7 @@ type LocksController struct { LockDetailTemplate TemplateWriter WorkingDir events.WorkingDir WorkingDirLocker events.WorkingDirLocker + DB *db.BoltDB } // GetLock is the GET /locks/{id} route. It renders the lock detail view. @@ -104,6 +106,9 @@ func (l *LocksController) DeleteLock(w http.ResponseWriter, r *http.Request) { l.Logger.Err("unable to delete workspace: %s", err) } } + if err := l.DB.DeleteProjectStatus(lock.Pull, lock.Workspace, lock.Project.Path); err != nil { + l.Logger.Err("unable to delete project status: %s", err) + } // Once the lock has been deleted, comment back on the pull request. comment := fmt.Sprintf("**Warning**: The plan for dir: `%s` workspace: `%s` was **discarded** via the Atlantis UI.\n\n"+ diff --git a/server/locks_controller_test.go b/server/locks_controller_test.go index 7d76130aa9..7539ad845d 100644 --- a/server/locks_controller_test.go +++ b/server/locks_controller_test.go @@ -3,6 +3,7 @@ package server_test import ( "bytes" "errors" + "github.com/runatlantis/atlantis/server/events/db" "net/http" "net/http/httptest" "net/url" @@ -204,12 +205,17 @@ func TestDeleteLock_CommentFailed(t *testing.T) { BaseRepo: models.Repo{FullName: "owner/repo"}, }, }, nil) + tmp, cleanup := TempDir(t) + defer cleanup() + db, err := db.New(tmp) + Ok(t, err) lc := server.LocksController{ Locker: l, Logger: logging.NewNoopLogger(), VCSClient: cp, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, + DB: db, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) @@ -237,12 +243,17 @@ func TestDeleteLock_CommentSuccess(t *testing.T) { RepoFullName: "owner/repo", }, }, nil) + tmp, cleanup := TempDir(t) + defer cleanup() + db, err := db.New(tmp) + Ok(t, err) lc := server.LocksController{ Locker: l, Logger: logging.NewNoopLogger(), VCSClient: cp, WorkingDirLocker: workingDirLocker, WorkingDir: workingDir, + DB: db, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) diff --git a/server/server.go b/server/server.go index a218376925..2f69bdbd8b 100644 --- a/server/server.go +++ b/server/server.go @@ -20,6 +20,7 @@ import ( "encoding/json" "flag" "fmt" + "github.com/runatlantis/atlantis/server/events/db" "log" "net/http" "net/url" @@ -34,7 +35,6 @@ import ( "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/locking" - "github.com/runatlantis/atlantis/server/events/locking/boltdb" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/runtime" "github.com/runatlantis/atlantis/server/events/terraform" @@ -175,7 +175,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { markdownRenderer := &events.MarkdownRenderer{ GitlabSupportsCommonMark: gitlabClient.SupportsCommonMark(), } - boltdb, err := boltdb.New(userConfig.DataDir) + boltdb, err := db.New(userConfig.DataDir) if err != nil { return nil, err } @@ -204,6 +204,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { VCSClient: vcsClient, Locker: lockingClient, WorkingDir: workingDir, + Logger: logger, + DB: boltdb, } eventParser := &events.EventParser{ GithubUser: userConfig.GithubUser, @@ -221,6 +223,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GitlabToken: userConfig.GitlabToken, } defaultTfVersion := terraformClient.Version() + pendingPlanFinder := &events.DefaultPendingPlanFinder{} commandRunner := &events.DefaultCommandRunner{ VCSClient: vcsClient, GithubPullGetter: githubClient, @@ -239,7 +242,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { WorkingDirLocker: workingDirLocker, AllowRepoConfig: userConfig.AllowRepoConfig, AllowRepoConfigFlag: config.AllowRepoConfigFlag, - PendingPlanFinder: &events.PendingPlanFinder{}, + PendingPlanFinder: pendingPlanFinder, CommentBuilder: commentParser, }, ProjectCommandRunner: &events.DefaultProjectCommandRunner{ @@ -267,6 +270,10 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { RequireApprovalOverride: userConfig.RequireApproval, RequireMergeableOverride: userConfig.RequireMergeable, }, + WorkingDir: workingDir, + PendingPlanFinder: pendingPlanFinder, + DB: boltdb, + GlobalAutomerge: userConfig.Automerge, } repoWhitelist, err := events.NewRepoWhitelistChecker(userConfig.RepoWhitelist) if err != nil { @@ -281,6 +288,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { LockDetailTemplate: lockTemplate, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, + DB: boltdb, } eventsController := &EventsController{ CommandRunner: commandRunner, diff --git a/server/testfixtures/test-repos/automerge/atlantis.yaml b/server/testfixtures/test-repos/automerge/atlantis.yaml new file mode 100644 index 0000000000..ffc80acf58 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/atlantis.yaml @@ -0,0 +1,5 @@ +version: 2 +automerge: true +projects: +- dir: dir1 +- dir: dir2 diff --git a/server/testfixtures/test-repos/automerge/dir1/main.tf b/server/testfixtures/test-repos/automerge/dir1/main.tf new file mode 100644 index 0000000000..e0dfc86862 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/dir1/main.tf @@ -0,0 +1,3 @@ +resource "null_resource" "automerge" { + count = 1 +} diff --git a/server/testfixtures/test-repos/automerge/dir2/main.tf b/server/testfixtures/test-repos/automerge/dir2/main.tf new file mode 100644 index 0000000000..e0dfc86862 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/dir2/main.tf @@ -0,0 +1,3 @@ +resource "null_resource" "automerge" { + count = 1 +} diff --git a/server/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt b/server/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt new file mode 100644 index 0000000000..45acf57008 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt @@ -0,0 +1,9 @@ +Ran Apply for dir: `dir1` workspace: `default` + +```diff +null_resource.automerge: Creating... +null_resource.automerge: Creation complete after *s (ID: ******************) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. +``` + diff --git a/server/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt b/server/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt new file mode 100644 index 0000000000..46096882e7 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt @@ -0,0 +1,9 @@ +Ran Apply for dir: `dir2` workspace: `default` + +```diff +null_resource.automerge: Creating... +null_resource.automerge: Creation complete after *s (ID: ******************) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. +``` + diff --git a/server/testfixtures/test-repos/automerge/exp-output-automerge.txt b/server/testfixtures/test-repos/automerge/exp-output-automerge.txt new file mode 100644 index 0000000000..74ec56bc01 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/exp-output-automerge.txt @@ -0,0 +1 @@ +Automatically merging because all plans have been successfully applied. diff --git a/server/testfixtures/test-repos/automerge/exp-output-autoplan.txt b/server/testfixtures/test-repos/automerge/exp-output-autoplan.txt new file mode 100644 index 0000000000..29c767bd90 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/exp-output-autoplan.txt @@ -0,0 +1,48 @@ +Ran Plan for 2 projects: +1. dir: `dir1` workspace: `default` +1. dir: `dir2` workspace: `default` + +### 1. dir: `dir1` workspace: `default` +```diff + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: ++ create + +Terraform will perform the following actions: + ++ null_resource.automerge + id: +Plan: 1 to add, 0 to change, 0 to destroy. +``` + +* :arrow_forward: To **apply** this plan, comment: + * `atlantis apply -d dir1` +* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) +* :repeat: To **plan** this project again, comment: + * `atlantis plan -d dir1` + +--- +### 2. dir: `dir2` workspace: `default` +```diff + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: ++ create + +Terraform will perform the following actions: + ++ null_resource.automerge + id: +Plan: 1 to add, 0 to change, 0 to destroy. +``` + +* :arrow_forward: To **apply** this plan, comment: + * `atlantis apply -d dir2` +* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) +* :repeat: To **plan** this project again, comment: + * `atlantis plan -d dir2` + +--- +* :fast_forward: To **apply** all unapplied plans from this pull request, comment: + * `atlantis apply` diff --git a/server/testfixtures/test-repos/automerge/exp-output-merge.txt b/server/testfixtures/test-repos/automerge/exp-output-merge.txt new file mode 100644 index 0000000000..9228a63148 --- /dev/null +++ b/server/testfixtures/test-repos/automerge/exp-output-merge.txt @@ -0,0 +1,4 @@ +Locks and plans deleted for the projects and workspaces modified in this pull request: + +- dir: `dir1` workspace: `default` +- dir: `dir2` workspace: `default` diff --git a/server/user_config.go b/server/user_config.go index 07bf60816c..83d4089e13 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -9,6 +9,7 @@ type UserConfig struct { AllowForkPRs bool `mapstructure:"allow-fork-prs"` AllowRepoConfig bool `mapstructure:"allow-repo-config"` AtlantisURL string `mapstructure:"atlantis-url"` + Automerge bool `mapstructure:"automerge"` BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` BitbucketToken string `mapstructure:"bitbucket-token"` BitbucketUser string `mapstructure:"bitbucket-user"` diff --git a/testdrive/utils.go b/testdrive/utils.go index ed20b85bd5..38869650ce 100644 --- a/testdrive/utils.go +++ b/testdrive/utils.go @@ -208,7 +208,7 @@ func execAndWaitForStderr(wg *sync.WaitGroup, stderrMatch *regexp.Regexp, timeou cancel() // We still need to wait for the command to finish. command.Wait() // nolint: errcheck - return cancel, errChan, fmt.Errorf("timeout, logs:\n%s\n", log) // nolint: staticcheck + return cancel, errChan, fmt.Errorf("timeout, logs:\n%s\n", log) // nolint: staticcheck, golint } // Increment the wait group so callers can wait for the command to finish.