diff --git a/cmd/server.go b/cmd/server.go index 36bd28e504..7867f160dd 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -55,6 +55,7 @@ const ( PortFlag = "port" RepoWhitelistFlag = "repo-whitelist" RequireApprovalFlag = "require-approval" + RebaseRepo = "rebase-repo" SilenceWhitelistErrorsFlag = "silence-whitelist-errors" SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" @@ -186,6 +187,11 @@ var boolFlags = []boolFlag{ description: "Require pull requests to be \"Approved\" before allowing the apply command to be run.", defaultValue: false, }, + { + name: RebaseRepo, + description: "Prior to running any commands the pull request is rebased off of master.", + defaultValue: false, + }, { name: SilenceWhitelistErrorsFlag, description: "Silences the posting of whitelist error comments.", diff --git a/server/events/command_context.go b/server/events/command_context.go index 497adcd504..235dbb54e4 100644 --- a/server/events/command_context.go +++ b/server/events/command_context.go @@ -30,6 +30,7 @@ type CommandContext struct { HeadRepo models.Repo Pull models.PullRequest // User is the user that triggered this command. - User models.User - Log *logging.SimpleLogger + User models.User + Log *logging.SimpleLogger + RebaseRepo bool } diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 2465b7ee09..dc2e10578a 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -69,17 +69,19 @@ type DefaultCommandRunner struct { AllowForkPRsFlag string ProjectCommandBuilder ProjectCommandBuilder ProjectCommandRunner ProjectCommandRunner + RebaseRepo bool } // RunAutoplanCommand runs plan when a pull request is opened or updated. func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) { log := c.buildLogger(baseRepo.FullName, pull.Num) ctx := &CommandContext{ - User: user, - Log: log, - Pull: pull, - HeadRepo: headRepo, - BaseRepo: baseRepo, + User: user, + Log: log, + Pull: pull, + HeadRepo: headRepo, + BaseRepo: baseRepo, + RebaseRepo: c.RebaseRepo, } defer c.logPanics(ctx) if !c.validateCtxAndComment(ctx) { @@ -138,11 +140,12 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead return } ctx := &CommandContext{ - User: user, - Log: log, - Pull: pull, - HeadRepo: headRepo, - BaseRepo: baseRepo, + User: user, + Log: log, + Pull: pull, + HeadRepo: headRepo, + BaseRepo: baseRepo, + RebaseRepo: c.RebaseRepo, } defer c.logPanics(ctx) if !c.validateCtxAndComment(ctx) { diff --git a/server/events/mocks/mock_working_dir.go b/server/events/mocks/mock_working_dir.go index 3f7b8f0e99..d12ed4593c 100644 --- a/server/events/mocks/mock_working_dir.go +++ b/server/events/mocks/mock_working_dir.go @@ -19,8 +19,8 @@ func NewMockWorkingDir() *MockWorkingDir { return &MockWorkingDir{fail: pegomock.GlobalFailHandler} } -func (mock *MockWorkingDir) Clone(log *logging.SimpleLogger, baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) { - params := []pegomock.Param{log, baseRepo, headRepo, p, workspace} +func (mock *MockWorkingDir) Clone(log *logging.SimpleLogger, baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, rebase bool, workspace string) (string, error) { + params := []pegomock.Param{log, baseRepo, headRepo, p, rebase, workspace} result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 error diff --git a/server/events/models/models.go b/server/events/models/models.go index 18b232bb4e..487c664095 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -301,6 +301,8 @@ type ProjectCommandContext struct { // ApplyCmd is the command that users should run to apply this plan. If // this is an apply then this will be empty. ApplyCmd string + + RebaseRepo bool } // SplitRepoFullName splits a repo full name up into its owner and repo name diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 9160588ea8..1426bcda8c 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -89,7 +89,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, ctx.Log.Debug("got workspace lock") defer unlockFn() - repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, workspace) + repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.RebaseRepo, workspace) if err != nil { return nil, err } @@ -143,6 +143,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, Verbose: verbose, RePlanCmd: p.CommentBuilder.BuildPlanComment(mp.Path, DefaultWorkspace, "", commentFlags), ApplyCmd: p.CommentBuilder.BuildApplyComment(mp.Path, DefaultWorkspace, ""), + RebaseRepo: ctx.RebaseRepo, }) } } else { @@ -172,6 +173,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, Verbose: verbose, RePlanCmd: p.CommentBuilder.BuildPlanComment(mp.Dir, mp.Workspace, mp.GetName(), commentFlags), ApplyCmd: p.CommentBuilder.BuildApplyComment(mp.Dir, mp.Workspace, mp.GetName()), + RebaseRepo: ctx.RebaseRepo, }) } } @@ -193,7 +195,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *CommandConte defer unlockFn() ctx.Log.Debug("cloning repository") - repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, workspace) + repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.RebaseRepo, workspace) if err != nil { return pcc, err } @@ -315,6 +317,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContex GlobalConfig: globalCfg, RePlanCmd: p.CommentBuilder.BuildPlanComment(repoRelDir, workspace, projectName, commentFlags), ApplyCmd: p.CommentBuilder.BuildApplyComment(repoRelDir, workspace, projectName), + RebaseRepo: ctx.RebaseRepo, }, nil } diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index a73b6692cb..891f83da65 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -179,7 +179,7 @@ projects: pull := models.PullRequest{} logger := logging.NewNoopLogger() workingDir := mocks.NewMockWorkingDir() - When(workingDir.Clone(logger, baseRepo, headRepo, pull, "default")).ThenReturn(tmpDir, nil) + When(workingDir.Clone(logger, baseRepo, headRepo, pull, false, "default")).ThenReturn(tmpDir, nil) if c.AtlantisYAML != "" { err := ioutil.WriteFile(filepath.Join(tmpDir, yaml.AtlantisYAMLFilename), []byte(c.AtlantisYAML), 0600) Ok(t, err) @@ -405,7 +405,7 @@ projects: expWorkspace = "default" } if cmdName == events.PlanCommand { - When(workingDir.Clone(logger, baseRepo, headRepo, pull, expWorkspace)).ThenReturn(tmpDir, nil) + When(workingDir.Clone(logger, baseRepo, headRepo, pull, false, expWorkspace)).ThenReturn(tmpDir, nil) } else { When(workingDir.GetWorkingDir(baseRepo, pull, expWorkspace)).ThenReturn(tmpDir, nil) } @@ -487,6 +487,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiPlanNoAtlantisYAML(t *testing.T) matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), + AnyBool(), AnyString())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClientProxy() When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{"project1/main.tf", "project2/main.tf"}, nil) @@ -540,6 +541,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiPlanNoAtlantisYAMLNoModified(t * matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), + AnyBool(), AnyString())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClientProxy() When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{}, nil) @@ -610,6 +612,7 @@ projects: matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), + AnyBool(), AnyString())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClientProxy() When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{ @@ -674,6 +677,7 @@ projects: matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), + AnyBool(), AnyString())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClientProxy() When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{"main.tf"}, nil) @@ -809,6 +813,7 @@ func TestDefaultProjectCommandBuilder_RepoConfigDisabled(t *testing.T) { matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), + AnyBool(), AnyString())).ThenReturn(repoDir, nil) When(workingDir.GetWorkingDir( matchers.AnyModelsRepo(), diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 8784e87de6..542a31e620 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -135,14 +135,16 @@ func (p *DefaultProjectCommandRunner) doPlan(ctx models.ProjectCommandContext) ( } defer unlockFn() - // Clone is idempotent so okay to run even if the repo was already cloned. - repoDir, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.Workspace) + // Clone is idempotent when rebase is not true so okay to run even if the repo was already cloned. + ctx.Log.Err("WHAT IS REBASE %v", ctx.RebaseRepo) + repoDir, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.RebaseRepo, ctx.Workspace) if cloneErr != nil { if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } return nil, "", cloneErr } + projAbsPath := filepath.Join(repoDir, ctx.RepoRelDir) // Use default stage unless another workflow is defined in config diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index d2d02bfdf6..464daca195 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -152,6 +152,7 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { matchers.AnyModelsRepo(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), + AnyBool(), AnyString(), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( diff --git a/server/events/working_dir.go b/server/events/working_dir.go index b00ab31af3..9ce26b656a 100644 --- a/server/events/working_dir.go +++ b/server/events/working_dir.go @@ -33,7 +33,7 @@ const workingDirPrefix = "repos" type WorkingDir interface { // Clone git clones headRepo, checks out the branch and then returns the // absolute path to the root of the cloned repo. - Clone(log *logging.SimpleLogger, baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) + Clone(log *logging.SimpleLogger, baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, rebase bool, workspace string) (string, error) // GetWorkingDir returns the path to the workspace for this repo and pull. // If workspace does not exist on disk, error will be of type os.IsNotExist. GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error) @@ -60,6 +60,7 @@ func (w *FileWorkspace) Clone( baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, + rebase bool, workspace string) (string, error) { cloneDir := w.cloneDir(baseRepo, p, workspace) @@ -72,27 +73,33 @@ func (w *FileWorkspace) Clone( 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)) - return w.forceClone(log, cloneDir, headRepo, p) + return w.forceClone(log, cloneDir, headRepo, p, rebase) } currCommit := strings.Trim(string(output), "\n") // We're prefix matching here because BitBucket doesn't give us the full // commit, only a 12 character prefix. - if strings.HasPrefix(currCommit, p.HeadCommit) { - log.Debug("repo is at correct commit %q so will not re-clone", p.HeadCommit) - return cloneDir, nil + if !rebase { + if strings.HasPrefix(currCommit, p.HeadCommit) { + log.Debug("repo is at correct commit %q so will not re-clone", p.HeadCommit) + return cloneDir, nil + } + log.Debug("repo was already cloned but is not at correct commit, wanted %q got %q", p.HeadCommit, currCommit) + } else { + log.Debug("rebase is specified so cloning") } - log.Debug("repo was already cloned but is not at correct commit, wanted %q got %q", p.HeadCommit, currCommit) + // We'll fall through to re-clone. } // Otherwise we clone the repo. - return w.forceClone(log, cloneDir, headRepo, p) + return w.forceClone(log, cloneDir, headRepo, p, rebase) } func (w *FileWorkspace) forceClone(log *logging.SimpleLogger, cloneDir string, headRepo models.Repo, - p models.PullRequest) (string, error) { + p models.PullRequest, + rebase bool) (string, error) { err := os.RemoveAll(cloneDir) if err != nil { @@ -122,6 +129,17 @@ func (w *FileWorkspace) forceClone(log *logging.SimpleLogger, if err := checkoutCmd.Run(); err != nil { return "", errors.Wrapf(err, "checking out branch %s", p.Branch) } + + if rebase { + // Rebase branch + log.Info("rebase branch onto master") + rebaseCmd := exec.Command("git", "rebase", "origin/master") // #nosec + rebaseCmd.Dir = cloneDir + if output, err := rebaseCmd.CombinedOutput(); err != nil { + return "", errors.Wrapf(err, "unable to rebase %s onto master %s", p.Branch, string(output)) + } + } + return cloneDir, nil } diff --git a/server/server.go b/server/server.go index 5128e9ce65..3dface7047 100644 --- a/server/server.go +++ b/server/server.go @@ -99,6 +99,7 @@ type UserConfig struct { GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` LogLevel string `mapstructure:"log-level"` Port int `mapstructure:"port"` + RebaseRepo bool `mapstructure:"rebase-repo"` RepoWhitelist string `mapstructure:"repo-whitelist"` // RequireApproval is whether to require pull request approval before // allowing terraform apply's to be run. @@ -263,6 +264,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Logger: logger, AllowForkPRs: userConfig.AllowForkPRs, AllowForkPRsFlag: config.AllowForkPRsFlag, + RebaseRepo: userConfig.RebaseRepo, ProjectCommandBuilder: &events.DefaultProjectCommandBuilder{ ParserValidator: &yaml.ParserValidator{}, ProjectFinder: &events.DefaultProjectFinder{},