Skip to content

Commit

Permalink
PR Locks for atlantis apply (#95)
Browse files Browse the repository at this point in the history
Added support for using πŸ”’/πŸ”“ to stop atlantis apply.
  • Loading branch information
Aayyush authored Aug 19, 2021
1 parent 576e9f5 commit 8d12b40
Show file tree
Hide file tree
Showing 22 changed files with 1,725 additions and 432 deletions.
23 changes: 15 additions & 8 deletions server/controllers/events/events_controller_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
runtimematchers "github.com/runatlantis/atlantis/server/events/runtime/mocks/matchers"
"github.com/runatlantis/atlantis/server/events/runtime/policy"
"github.com/runatlantis/atlantis/server/events/terraform"
"github.com/runatlantis/atlantis/server/events/vcs"
vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks"
"github.com/runatlantis/atlantis/server/events/webhooks"
"github.com/runatlantis/atlantis/server/events/yaml"
Expand Down Expand Up @@ -390,7 +391,7 @@ func TestGitHubWorkflow(t *testing.T) {
userConfig = server.UserConfig{}
userConfig.DisableApply = c.DisableApply

ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir)
ctrl, vcsClient, githubGetter, atlantisWorkspace, _ := setupE2E(t, c.RepoDir)
// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA, cleanup := initializeRepo(t, c.RepoDir)
defer cleanup()
Expand Down Expand Up @@ -580,16 +581,17 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
userConfig = server.UserConfig{}
userConfig.EnablePolicyChecksFlag = true

ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir)
ctrl, vcsClient, githubGetter, atlantisWorkspace, githubClient := setupE2E(t, c.RepoDir)

// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA, cleanup := initializeRepo(t, c.RepoDir)
defer cleanup()
atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir)

// Setup test dependencies.
w := httptest.NewRecorder()
When(vcsClient.PullIsMergeable(AnyRepo(), matchers.AnyModelsPullRequest())).ThenReturn(true, nil)
When(vcsClient.PullIsApproved(AnyRepo(), matchers.AnyModelsPullRequest())).ThenReturn(true, nil)
When(githubClient.PullIsSQMergeable(AnyRepo(), matchers.AnyModelsPullRequest(), AnyStatus())).ThenReturn(true, nil)
When(githubClient.PullIsApproved(AnyRepo(), matchers.AnyModelsPullRequest())).ThenReturn(true, nil)
When(githubGetter.GetPullRequest(AnyRepo(), AnyInt())).ThenReturn(GitHubPullRequestParsed(headSHA), nil)
When(vcsClient.GetModifiedFiles(AnyRepo(), matchers.AnyModelsPullRequest())).ThenReturn(c.ModifiedFiles, nil)

Expand Down Expand Up @@ -651,7 +653,7 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
}
}

func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) {
func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace, *vcsmocks.MockIGithubClient) {
allowForkPRs := false
dataDir, binDir, cacheDir, cleanup := mkSubDirs(t)
defer cleanup()
Expand Down Expand Up @@ -798,8 +800,7 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl
WorkingDirLocker: locker,
ProjectCmdOutputHandler: projectCmdOutputHandler,
AggregateApplyRequirements: &events.AggregateApplyRequirements{
PullApprovedChecker: e2eVCSClient,
WorkingDir: workingDir,
WorkingDir: workingDir,
},
}

Expand Down Expand Up @@ -845,6 +846,11 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl
boltdb,
)

e2eMockGithubClient := vcsmocks.NewMockIGithubClient()
e2ePullReqStatusFetcher := vcs.SQBasedPullStatusFetcher{
GithubClient: e2eMockGithubClient,
}

applyCommandRunner := events.NewApplyCommandRunner(
e2eVCSClient,
false,
Expand All @@ -859,6 +865,7 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl
parallelPoolSize,
silenceNoProjects,
false,
&e2ePullReqStatusFetcher,
)

approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner(
Expand Down Expand Up @@ -923,7 +930,7 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl
SupportedVCSHosts: []models.VCSHostType{models.Gitlab, models.Github, models.BitbucketCloud},
VCSClient: e2eVCSClient,
}
return ctrl, e2eVCSClient, e2eGithubGetter, workingDir
return ctrl, e2eVCSClient, e2eGithubGetter, workingDir, e2eMockGithubClient
}

type mockLockURLGenerator struct{}
Expand Down
6 changes: 6 additions & 0 deletions server/controllers/events/events_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"strings"
"testing"

"github.com/google/go-github/v31/github"
. "github.com/petergtz/pegomock"
events_controllers "github.com/runatlantis/atlantis/server/controllers/events"
"github.com/runatlantis/atlantis/server/controllers/events/mocks"
Expand All @@ -49,6 +50,11 @@ func AnyRepo() models.Repo {
return models.Repo{}
}

func AnyStatus() []*github.RepoStatus {
RegisterMatcher(NewAnyMatcher(reflect.TypeOf(github.RepoStatus{})))
return []*github.RepoStatus{}
}

func TestPost_NotGithubOrGitlab(t *testing.T) {
t.Log("when the request is not for gitlab or github a 400 is returned")
e, _, _, _, _, _, _, _ := setup(t)
Expand Down
34 changes: 18 additions & 16 deletions server/events/apply_command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func NewApplyCommandRunner(
parallelPoolSize int,
SilenceNoProjects bool,
silenceVCSStatusNoProjects bool,
pullReqStatusFetcher vcs.PullReqStatusFetcher,
) *ApplyCommandRunner {
return &ApplyCommandRunner{
vcsClient: vcsClient,
Expand All @@ -36,21 +37,23 @@ func NewApplyCommandRunner(
parallelPoolSize: parallelPoolSize,
SilenceNoProjects: SilenceNoProjects,
silenceVCSStatusNoProjects: silenceVCSStatusNoProjects,
pullReqStatusFetcher: pullReqStatusFetcher,
}
}

type ApplyCommandRunner struct {
DisableApplyAll bool
DB *db.BoltDB
locker locking.ApplyLockChecker
vcsClient vcs.Client
commitStatusUpdater CommitStatusUpdater
prjCmdBuilder ProjectApplyCommandBuilder
prjCmdRunner ProjectApplyCommandRunner
autoMerger *AutoMerger
pullUpdater *PullUpdater
dbUpdater *DBUpdater
parallelPoolSize int
DisableApplyAll bool
DB *db.BoltDB
locker locking.ApplyLockChecker
vcsClient vcs.Client
commitStatusUpdater CommitStatusUpdater
prjCmdBuilder ProjectApplyCommandBuilder
prjCmdRunner ProjectApplyCommandRunner
autoMerger *AutoMerger
pullUpdater *PullUpdater
dbUpdater *DBUpdater
parallelPoolSize int
pullReqStatusFetcher vcs.PullReqStatusFetcher
// SilenceNoProjects is whether Atlantis should respond to PRs if no projects
// are found
SilenceNoProjects bool
Expand Down Expand Up @@ -98,17 +101,16 @@ func (a *ApplyCommandRunner) Run(ctx *CommandContext, cmd *CommentCommand) {
// We do this here because when we set a "Pending" status, if users have
// required the Atlantis status checks to pass, then we've now changed
// the mergeability status of the pull request.
ctx.PullMergeable, err = a.vcsClient.PullIsMergeable(baseRepo, pull)
// This sets the approved, mergeable, and sqlocked status in the context.
ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(baseRepo, pull)
if err != nil {
// On error we continue the request with mergeable assumed false.
// We want to continue because not all apply's will need this status,
// only if they rely on the mergeability requirement.
ctx.PullMergeable = false
ctx.Log.Warn("unable to get mergeable status: %s. Continuing with mergeable assumed false", err)
// All PullRequestStatus fields are set to false by default when error.
ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err)
}

ctx.Log.Info("pull request mergeable status: %t", ctx.PullMergeable)

var projectCmds []models.ProjectCommandContext
projectCmds, err = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd)

Expand Down
25 changes: 14 additions & 11 deletions server/events/apply_requirement_handler.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package events

import (
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/runtime"
"github.com/runatlantis/atlantis/server/events/yaml/raw"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/feature"
)

//go:generate pegomock generate -m --package mocks -o mocks/mock_apply_handler.go ApplyRequirement
Expand All @@ -14,20 +13,15 @@ type ApplyRequirement interface {
}

type AggregateApplyRequirements struct {
PullApprovedChecker runtime.PullApprovedChecker
WorkingDir WorkingDir
WorkingDir WorkingDir
FeatureAllocator feature.Allocator
}

func (a *AggregateApplyRequirements) ValidateProject(repoDir string, ctx models.ProjectCommandContext) (failure string, err error) {

for _, req := range ctx.ApplyRequirements {
switch req {
case raw.ApprovedApplyRequirement:
approved, err := a.PullApprovedChecker.PullIsApproved(ctx.Pull.BaseRepo, ctx.Pull) // nolint: vetshadow
if err != nil {
return "", errors.Wrap(err, "checking if pull request was approved")
}
if !approved {
if !ctx.PullReqStatus.Approved {
return "Pull request must be approved by at least one person other than the author before running apply.", nil
}
// this should come before mergeability check since mergeability is a superset of this check.
Expand All @@ -36,13 +30,22 @@ func (a *AggregateApplyRequirements) ValidateProject(repoDir string, ctx models.
return "All policies must pass for project before running apply", nil
}
case raw.MergeableApplyRequirement:
if !ctx.PullMergeable {
if !ctx.PullReqStatus.Mergeable {
return "Pull request must be mergeable before running apply.", nil
}
case raw.UnDivergedApplyRequirement:
if a.WorkingDir.HasDiverged(ctx.Log, repoDir) {
return "Default branch must be rebased onto pull request before running apply.", nil
}
case raw.UnlockedApplyRequirement:
shouldAllocate, err := a.FeatureAllocator.ShouldAllocate(feature.AtlantisLock, ctx.BaseRepo.FullName)
if err != nil {
ctx.Log.Err("unable to allocate for feature: %s, error: %s", feature.AtlantisLock, err)
}

if shouldAllocate && ctx.PullReqStatus.SQLocked {
return "Pull request must be unlocked using the πŸ”“ emoji before running apply.", nil
}
}
}
// Passed all apply requirements configured.
Expand Down
3 changes: 3 additions & 0 deletions server/events/command_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type CommandContext struct {
// required the Atlantis status to be successful prior to merging.
PullMergeable bool

// Current PR state
PullRequestStatus models.PullReqStatus

PullStatus *models.PullStatus

Trigger CommandTrigger
Expand Down
7 changes: 7 additions & 0 deletions server/events/command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

stats "github.com/lyft/gostats"
"github.com/runatlantis/atlantis/server/events/db"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/logging"

Expand Down Expand Up @@ -69,6 +70,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
projectCommandBuilder = mocks.NewMockProjectCommandBuilder()
eventParsing = mocks.NewMockEventParsing()
vcsClient := vcsmocks.NewMockClient()
githubClient := vcsmocks.NewMockIGithubClient()
githubGetter = mocks.NewMockGithubPullGetter()
gitlabGetter = mocks.NewMockGitlabMergeRequestGetter()
azuredevopsGetter = mocks.NewMockAzureDevopsPullGetter()
Expand Down Expand Up @@ -131,6 +133,10 @@ func setup(t *testing.T) *vcsmocks.MockClient {
defaultBoltDB,
)

pullReqStatusFetcher := vcs.SQBasedPullStatusFetcher{
GithubClient: githubClient,
}

applyCommandRunner = events.NewApplyCommandRunner(
vcsClient,
false,
Expand All @@ -145,6 +151,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
parallelPoolSize,
SilenceNoProjects,
false,
&pullReqStatusFetcher,
)

approvePoliciesCommandRunner = events.NewApprovePoliciesCommandRunner(
Expand Down
10 changes: 9 additions & 1 deletion server/events/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ const (
planfileSlashReplace = "::"
)

type PullReqStatus struct {
Approved bool
Mergeable bool
SQLocked bool
}

// Repo is a VCS repository.
type Repo struct {
// FullName is the owner and repo name separated
Expand Down Expand Up @@ -374,6 +380,8 @@ type ProjectCommandContext struct {
Scope stats.Scope
// PullMergeable is true if the pull request for this project is able to be merged.
PullMergeable bool
// PullReqStatus holds state about the PR that requires additional computation outside models.PullRequest
PullReqStatus PullReqStatus
// CurrentProjectPlanStatus is the status of the current project prior to this command.
ProjectPlanStatus ProjectPlanStatus
// Pull is the pull request we're responding to.
Expand Down Expand Up @@ -425,7 +433,7 @@ func (p ProjectCommandContext) ProjectCloneDir() string {
// SetScope sets the scope of the stats object field. Note: we deliberately set this on the value
// instead of a pointer since we want scopes to mirror our function stack
func (p ProjectCommandContext) SetScope(scope string) {
p.Scope = p.Scope.Scope(scope)
p.Scope = p.Scope.Scope(scope) //nolint
}

// GetShowResultFileName returns the filename (not the path) to store the tf show result
Expand Down
9 changes: 7 additions & 2 deletions server/events/project_command_context_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext(
prjCfg.TerraformVersion = getTfVersion(ctx, filepath.Join(repoDir, prjCfg.RepoRelDir))
}

projectCmds = append(projectCmds, newProjectCommandContext(
projectCmdContext := newProjectCommandContext(
ctx,
cmdName,
cb.CommentBuilder.BuildApplyComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, prjCfg.AutoMergeDisabled),
Expand All @@ -121,7 +121,12 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext(
parallelPlan,
verbose,
ctx.Scope,
))
)

// Map the CommandContext PullReqStatus to ProjectCommandContext PullReqStatus.
projectCmdContext.PullReqStatus = ctx.PullRequestStatus

projectCmds = append(projectCmds, projectCmdContext)

return
}
Expand Down
16 changes: 7 additions & 9 deletions server/events/project_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,12 @@ func TestDefaultProjectCommandRunner_ApplyNotCloned(t *testing.T) {
func TestDefaultProjectCommandRunner_ApplyNotApproved(t *testing.T) {
RegisterMockTestingT(t)
mockWorkingDir := mocks.NewMockWorkingDir()
mockApproved := mocks2.NewMockPullApprovedChecker()
mockPullReqStatusChecker := mocks2.NewMockPullStatusChecker()
runner := &events.DefaultProjectCommandRunner{
WorkingDir: mockWorkingDir,
WorkingDirLocker: events.NewDefaultWorkingDirLocker(),
AggregateApplyRequirements: &events.AggregateApplyRequirements{
PullApprovedChecker: mockApproved,
WorkingDir: mockWorkingDir,
WorkingDir: mockWorkingDir,
},
}
ctx := models.ProjectCommandContext{
Expand All @@ -165,7 +164,7 @@ func TestDefaultProjectCommandRunner_ApplyNotApproved(t *testing.T) {
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)
When(mockPullReqStatusChecker.PullIsApproved(ctx.BaseRepo, ctx.Pull)).ThenReturn(false, nil)

res := runner.Apply(ctx)
Equals(t, "Pull request must be approved by at least one person other than the author before running apply.", res.Failure)
Expand Down Expand Up @@ -303,13 +302,12 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) {
mockApply := mocks.NewMockStepRunner()
mockRun := mocks.NewMockCustomStepRunner()
mockEnv := mocks.NewMockEnvStepRunner()
mockApproved := mocks2.NewMockPullApprovedChecker()
mockPullReqStatusChecker := mocks2.NewMockPullStatusChecker()
mockWorkingDir := mocks.NewMockWorkingDir()
mockLocker := mocks.NewMockProjectLocker()
mockSender := mocks.NewMockWebhooksSender()
applyReqHandler := &events.AggregateApplyRequirements{
PullApprovedChecker: mockApproved,
WorkingDir: mockWorkingDir,
WorkingDir: mockWorkingDir,
}

runner := events.DefaultProjectCommandRunner{
Expand Down Expand Up @@ -349,7 +347,7 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) {
When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", nil)
When(mockRun.Run(ctx, "", repoDir, expEnvs)).ThenReturn("run", nil)
When(mockEnv.Run(ctx, "", "value", repoDir, make(map[string]string))).ThenReturn("value", nil)
When(mockApproved.PullIsApproved(ctx.BaseRepo, ctx.Pull)).ThenReturn(true, nil)
When(mockPullReqStatusChecker.PullIsApproved(ctx.BaseRepo, ctx.Pull)).ThenReturn(true, nil)

res := runner.Apply(ctx)
Equals(t, c.expOut, res.ApplySuccess)
Expand All @@ -358,7 +356,7 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) {
for _, step := range c.expSteps {
switch step {
case "approved":
mockApproved.VerifyWasCalledOnce().PullIsApproved(ctx.BaseRepo, ctx.Pull)
mockPullReqStatusChecker.VerifyWasCalledOnce().PullIsApproved(ctx.BaseRepo, ctx.Pull)
case "init":
mockInit.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs)
case "plan":
Expand Down
Loading

0 comments on commit 8d12b40

Please sign in to comment.