Add checkout-strategy flag.

This flag can be set to either branch (default) or merge. If set to
branch, we will check out the head branch of the pull request (the
source). If set to merge, we will check out the base branch of the pull
request (the destination) and then attempt to perform a git merge of the
pull request branch. This simulates what will happen if the pull request
is merged.

This allows us to perform terraform commands on what the state of the
repo will be *after* the pull request is merged. This is useful if users
are often opening up pull requests from branches that aren't up to date
with master. With the branch strategy, the terraform plan might be
deleting resources that have been created in the master branch but with
the merge strategy, we merge the branch's changes into the master branch
and so don't have this problem.
This commit is contained in:
Luke Kysow
2019-01-15 14:19:04 -05:00
parent d2abdb569a
commit 8b77b95a16
6 changed files with 109 additions and 17 deletions

View File

@@ -44,6 +44,7 @@ const (
BitbucketUserFlag = "bitbucket-user"
BitbucketWebhookSecretFlag = "bitbucket-webhook-secret"
ConfigFlag = "config"
CheckoutStrategyFlag = "checkout-strategy"
DataDirFlag = "data-dir"
GHHostnameFlag = "gh-hostname"
GHTokenFlag = "gh-token"
@@ -64,6 +65,7 @@ const (
TFETokenFlag = "tfe-token"
// Flag defaults.
DefaultCheckoutStrategy = "branch"
DefaultBitbucketBaseURL = bitbucketcloud.BaseURL
DefaultDataDir = "~/.atlantis"
DefaultGHHostname = "github.com"
@@ -103,6 +105,16 @@ var stringFlags = []stringFlag{
name: ConfigFlag,
description: "Path to config file. All flags can be set in a YAML config file instead.",
},
{
name: CheckoutStrategyFlag,
description: "How to check out pull requests. Accepts either 'branch' (default) or 'merge'." +
" If set to branch, Atlantis will check out the source branch of the pull request." +
" If set to merge, Atlantis will check out the destination branch of the pull request (ex. master)" +
" and then locally perform a git merge of the source branch." +
" This effectively means Atlantis operates on the repo as it will look" +
" after the pull request is merged.",
defaultValue: "branch",
},
{
name: DataDirFlag,
description: "Path to directory to store Atlantis data.",
@@ -372,6 +384,9 @@ func (s *ServerCmd) run() error {
}
func (s *ServerCmd) setDefaults(c *server.UserConfig) {
if c.CheckoutStrategy == "" {
c.CheckoutStrategy = DefaultCheckoutStrategy
}
if c.DataDir == "" {
c.DataDir = DefaultDataDir
}
@@ -397,6 +412,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
if logLevel != "debug" && logLevel != "info" && logLevel != "warn" && logLevel != "error" {
return errors.New("invalid log level: not one of debug, info, warn, error")
}
checkoutStrat := userConfig.CheckoutStrategy
if checkoutStrat != "branch" && checkoutStrat != "merge" {
return errors.New("invalid checkout strategy: not one of branch or merge")
}
if (userConfig.SSLKeyFile == "") != (userConfig.SSLCertFile == "") {
return fmt.Errorf("--%s and --%s are both required for ssl", SSLKeyFileFlag, SSLCertFileFlag)

View File

@@ -127,6 +127,14 @@ func TestExecute_ValidateLogLevel(t *testing.T) {
Equals(t, "invalid log level: not one of debug, info, warn, error", err.Error())
}
func TestExecute_ValidateCheckoutStrategy(t *testing.T) {
c := setupWithDefaults(map[string]interface{}{
cmd.CheckoutStrategyFlag: "invalid",
})
err := c.Execute()
ErrEquals(t, "invalid checkout strategy: not one of branch or merge", err)
}
func TestExecute_ValidateSSLConfig(t *testing.T) {
expErr := "--ssl-key-file and --ssl-cert-file are both required for ssl"
cases := []struct {
@@ -331,6 +339,7 @@ func TestExecute_Defaults(t *testing.T) {
Ok(t, err)
Equals(t, dataDir, passedConfig.DataDir)
Equals(t, "branch", passedConfig.CheckoutStrategy)
Equals(t, "github.com", passedConfig.GithubHostname)
Equals(t, "token", passedConfig.GithubToken)
Equals(t, "user", passedConfig.GithubUser)
@@ -432,6 +441,7 @@ func TestExecute_Flags(t *testing.T) {
cmd.BitbucketTokenFlag: "bitbucket-token",
cmd.BitbucketUserFlag: "bitbucket-user",
cmd.BitbucketWebhookSecretFlag: "bitbucket-secret",
cmd.CheckoutStrategyFlag: "merge",
cmd.DataDirFlag: "/path",
cmd.GHHostnameFlag: "ghhostname",
cmd.GHTokenFlag: "token",
@@ -460,6 +470,7 @@ func TestExecute_Flags(t *testing.T) {
Equals(t, "bitbucket-token", passedConfig.BitbucketToken)
Equals(t, "bitbucket-user", passedConfig.BitbucketUser)
Equals(t, "bitbucket-secret", passedConfig.BitbucketWebhookSecret)
Equals(t, "merge", passedConfig.CheckoutStrategy)
Equals(t, "/path", passedConfig.DataDir)
Equals(t, "ghhostname", passedConfig.GithubHostname)
Equals(t, "token", passedConfig.GithubToken)
@@ -489,6 +500,7 @@ bitbucket-base-url: "https://mydomain.com"
bitbucket-token: "bitbucket-token"
bitbucket-user: "bitbucket-user"
bitbucket-webhook-secret: "bitbucket-secret"
checkout-strategy: "merge"
data-dir: "/path"
gh-hostname: "ghhostname"
gh-token: "token"
@@ -521,6 +533,7 @@ tfe-token: my-token
Equals(t, "bitbucket-token", passedConfig.BitbucketToken)
Equals(t, "bitbucket-user", passedConfig.BitbucketUser)
Equals(t, "bitbucket-secret", passedConfig.BitbucketWebhookSecret)
Equals(t, "merge", passedConfig.CheckoutStrategy)
Equals(t, "/path", passedConfig.DataDir)
Equals(t, "ghhostname", passedConfig.GithubHostname)
Equals(t, "token", passedConfig.GithubToken)
@@ -550,6 +563,7 @@ bitbucket-base-url: "https://mydomain.com"
bitbucket-token: "bitbucket-token"
bitbucket-user: "bitbucket-user"
bitbucket-webhook-secret: "bitbucket-secret"
checkout-strategy: "merge"
data-dir: "/path"
gh-hostname: "ghhostname"
gh-token: "token"
@@ -578,6 +592,7 @@ ssl-key-file: my-token
"BITBUCKET_TOKEN": "override-bitbucket-token",
"BITBUCKET_USER": "override-bitbucket-user",
"BITBUCKET_WEBHOOK_SECRET": "override-bitbucket-secret",
"CHECKOUT_STRATEGY": "branch",
"DATA_DIR": "/override-path",
"GH_HOSTNAME": "override-gh-hostname",
"GH_TOKEN": "override-gh-token",
@@ -610,6 +625,7 @@ ssl-key-file: my-token
Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken)
Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser)
Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret)
Equals(t, "branch", passedConfig.CheckoutStrategy)
Equals(t, "/override-path", passedConfig.DataDir)
Equals(t, "override-gh-hostname", passedConfig.GithubHostname)
Equals(t, "override-gh-token", passedConfig.GithubToken)
@@ -639,6 +655,7 @@ bitbucket-base-url: "https://bitbucket-base-url"
bitbucket-token: "bitbucket-token"
bitbucket-user: "bitbucket-user"
bitbucket-webhook-secret: "bitbucket-secret"
checkout-strategy: "merge"
data-dir: "/path"
gh-hostname: "ghhostname"
gh-token: "token"
@@ -667,6 +684,7 @@ tfe-token: my-token
cmd.BitbucketTokenFlag: "override-bitbucket-token",
cmd.BitbucketUserFlag: "override-bitbucket-user",
cmd.BitbucketWebhookSecretFlag: "override-bitbucket-secret",
cmd.CheckoutStrategyFlag: "branch",
cmd.DataDirFlag: "/override-path",
cmd.GHHostnameFlag: "override-gh-hostname",
cmd.GHTokenFlag: "override-gh-token",
@@ -693,6 +711,7 @@ tfe-token: my-token
Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken)
Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser)
Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret)
Equals(t, "branch", passedConfig.CheckoutStrategy)
Equals(t, "/override-path", passedConfig.DataDir)
Equals(t, "override-gh-hostname", passedConfig.GithubHostname)
Equals(t, "override-gh-token", passedConfig.GithubToken)
@@ -723,6 +742,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) {
"BITBUCKET_TOKEN": "bitbucket-token",
"BITBUCKET_USER": "bitbucket-user",
"BITBUCKET_WEBHOOK_SECRET": "bitbucket-secret",
"CHECKOUT_STRATEGY": "merge",
"DATA_DIR": "/path",
"GH_HOSTNAME": "gh-hostname",
"GH_TOKEN": "gh-token",
@@ -759,6 +779,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) {
cmd.BitbucketTokenFlag: "override-bitbucket-token",
cmd.BitbucketUserFlag: "override-bitbucket-user",
cmd.BitbucketWebhookSecretFlag: "override-bitbucket-secret",
cmd.CheckoutStrategyFlag: "branch",
cmd.DataDirFlag: "/override-path",
cmd.GHHostnameFlag: "override-gh-hostname",
cmd.GHTokenFlag: "override-gh-token",
@@ -787,6 +808,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) {
Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken)
Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser)
Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret)
Equals(t, "branch", passedConfig.CheckoutStrategy)
Equals(t, "/override-path", passedConfig.DataDir)
Equals(t, "override-gh-hostname", passedConfig.GithubHostname)
Equals(t, "override-gh-token", passedConfig.GithubToken)

View File

@@ -14,6 +14,7 @@
package events
import (
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -46,6 +47,11 @@ type WorkingDir interface {
// FileWorkspace implements WorkingDir with the file system.
type FileWorkspace struct {
DataDir string
// CheckoutMerge is true if we should check out the branch that corresponds
// to what the base branch will look like *after* the pull request is merged.
// If this is false, then we will check out the head branch from the pull
// request.
CheckoutMerge bool
// TestingOverrideCloneURL can be used during testing to override the URL
// that is cloned. If it's empty then we clone normally.
TestingOverrideCloneURL string
@@ -67,11 +73,21 @@ func (w *FileWorkspace) Clone(
// If so, then we do nothing.
if _, err := os.Stat(cloneDir); err == nil {
log.Debug("clone directory %q already exists, checking if it's at the right commit", cloneDir)
revParseCmd := exec.Command("git", "rev-parse", "HEAD") // #nosec
// We use git rev-parse to see if our repo is at the right commit.
// If just checking out the pull request branch, we can use HEAD.
// If doing a merge, then HEAD won't be at the pull request's HEAD
// because we'll already have performed a merge. Instead, we'll check
// HEAD^2 since that will be the commit before our merge.
pullHead := "HEAD"
if w.CheckoutMerge {
pullHead = "HEAD^2"
}
revParseCmd := exec.Command("git", "rev-parse", pullHead) // #nosec
revParseCmd.Dir = cloneDir
output, err := revParseCmd.CombinedOutput()
if err != nil {
log.Err("will re-clone repo, could not determine if was at correct commit: git rev-parse HEAD: %s: %s", err, string(output))
log.Err("will re-clone repo, could not determine if was at correct commit: %s: %s: %s", strings.Join(revParseCmd.Args, " "), err, string(output))
return w.forceClone(log, cloneDir, headRepo, p)
}
currCommit := strings.Trim(string(output), "\n")
@@ -105,22 +121,48 @@ func (w *FileWorkspace) forceClone(log *logging.SimpleLogger,
return "", errors.Wrap(err, "creating new workspace")
}
log.Info("git cloning %q into %q", headRepo.SanitizedCloneURL, cloneDir)
cloneURL := headRepo.CloneURL
if w.TestingOverrideCloneURL != "" {
cloneURL = w.TestingOverrideCloneURL
}
cloneCmd := exec.Command("git", "clone", cloneURL, cloneDir) // #nosec
if output, err := cloneCmd.CombinedOutput(); err != nil {
return "", errors.Wrapf(err, "cloning %s: %s", headRepo.SanitizedCloneURL, string(output))
var cmds [][]string
if w.CheckoutMerge {
// NOTE: We can't do a shallow clone when we're merging because we'll
// get merge conflicts if our clone doesn't have the commits that the
// branch we're merging branched off at.
// See https://groups.google.com/forum/#!topic/git-users/v3MkuuiDJ98.
cmds = [][]string{
{
"git", "clone", "--branch", p.BaseBranch, "--single-branch", p.BaseRepo.CloneURL, cloneDir,
},
{
"git", "remote", "add", "head", headRepo.CloneURL,
},
{
"git", "fetch", "head", fmt.Sprintf("+refs/heads/%s:", p.HeadBranch),
},
{
"git", "merge", "-q", "-m", "atlantis-merge", "FETCH_HEAD",
},
}
} else {
cloneURL := headRepo.CloneURL
if w.TestingOverrideCloneURL != "" {
cloneURL = w.TestingOverrideCloneURL
}
cmds = [][]string{
{
"git", "clone", "--branch", p.HeadBranch, "--depth=1", "--single-branch", cloneURL, cloneDir,
},
}
}
// Check out the branch for this PR.
log.Info("checking out branch %q", p.Branch)
checkoutCmd := exec.Command("git", "checkout", p.Branch) // #nosec
checkoutCmd.Dir = cloneDir
if err := checkoutCmd.Run(); err != nil {
return "", errors.Wrapf(err, "checking out branch %s", p.Branch)
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = cloneDir
cmdStr := w.cmdAsSanitizedStr(cmd, p.BaseRepo, headRepo)
output, err := cmd.CombinedOutput()
if err != nil {
return "", errors.Wrapf(err, "running %s: %s", cmdStr, string(output))
}
log.Debug("ran: %s. Output: %s", cmdStr, string(output))
}
return cloneDir, nil
}
@@ -161,3 +203,9 @@ func (w *FileWorkspace) repoPullDir(r models.Repo, p models.PullRequest) string
func (w *FileWorkspace) cloneDir(r models.Repo, p models.PullRequest, workspace string) string {
return filepath.Join(w.repoPullDir(r, p), workspace)
}
func (w *FileWorkspace) cmdAsSanitizedStr(cmd *exec.Cmd, base models.Repo, head models.Repo) string {
cmdAsStr := strings.Join(cmd.Args, " ")
baseReplaced := strings.Replace(cmdAsStr, base.CloneURL, base.SanitizedCloneURL, -1)
return strings.Replace(baseReplaced, head.CloneURL, head.SanitizedCloneURL, -1)
}

View File

@@ -425,6 +425,7 @@ func GitHubPullRequestParsed(headSHA string) *github.PullRequest {
FullName: github.String("runatlantis/atlantis-tests"),
CloneURL: github.String("/runatlantis/atlantis-tests.git"),
},
Ref: github.String("master"),
},
User: &github.User{
Login: github.String("atlantisbot"),

View File

@@ -182,7 +182,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
lockingClient := locking.NewClient(boltdb)
workingDirLocker := events.NewDefaultWorkingDirLocker()
workingDir := &events.FileWorkspace{
DataDir: userConfig.DataDir,
DataDir: userConfig.DataDir,
CheckoutMerge: userConfig.CheckoutStrategy == "merge",
}
projectLocker := &events.DefaultProjectLocker{
Locker: lockingClient,

View File

@@ -13,6 +13,7 @@ type UserConfig struct {
BitbucketToken string `mapstructure:"bitbucket-token"`
BitbucketUser string `mapstructure:"bitbucket-user"`
BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"`
CheckoutStrategy string `mapstructure:"checkout-strategy"`
DataDir string `mapstructure:"data-dir"`
GithubHostname string `mapstructure:"gh-hostname"`
GithubToken string `mapstructure:"gh-token"`