diff --git a/server/events/apply_command_runner.go b/server/events/apply_command_runner.go index aa5a4a652b..2ccc75bf0f 100644 --- a/server/events/apply_command_runner.go +++ b/server/events/apply_command_runner.go @@ -2,6 +2,7 @@ package events import ( "github.com/runatlantis/atlantis/server/events/db" + "github.com/runatlantis/atlantis/server/events/locking" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) @@ -9,7 +10,7 @@ import ( func NewApplyCommandRunner( vcsClient vcs.Client, disableApplyAll bool, - disableApply bool, + applyCommandLocker locking.ApplyLockChecker, commitStatusUpdater CommitStatusUpdater, prjCommandBuilder ProjectApplyCommandBuilder, prjCmdRunner ProjectApplyCommandRunner, @@ -22,7 +23,7 @@ func NewApplyCommandRunner( return &ApplyCommandRunner{ vcsClient: vcsClient, DisableApplyAll: disableApplyAll, - DisableApply: disableApply, + locker: applyCommandLocker, commitStatusUpdater: commitStatusUpdater, prjCmdBuilder: prjCommandBuilder, prjCmdRunner: prjCmdRunner, @@ -36,8 +37,8 @@ func NewApplyCommandRunner( type ApplyCommandRunner struct { DisableApplyAll bool - DisableApply bool DB *db.BoltDB + locker locking.ApplyLockChecker vcsClient vcs.Client commitStatusUpdater CommitStatusUpdater prjCmdBuilder ProjectApplyCommandBuilder @@ -53,7 +54,15 @@ func (a *ApplyCommandRunner) Run(ctx *CommandContext, cmd *CommentCommand) { baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull - if a.DisableApply { + locked, err := a.IsLocked() + // CheckApplyLock falls back to DisableApply flag if fetching the lock + // raises an erro r + // We will log failure as warning + if err != nil { + ctx.Log.Warn("checking global apply lock: %s", err) + } + + if locked { ctx.Log.Info("ignoring apply command since apply disabled globally") if err := a.vcsClient.CreateComment(baseRepo, pull.Num, applyDisabledComment, models.ApplyCommand.String()); err != nil { ctx.Log.Err("unable to comment on pull request: %s", err) @@ -135,6 +144,12 @@ func (a *ApplyCommandRunner) Run(ctx *CommandContext, cmd *CommentCommand) { } } +func (a *ApplyCommandRunner) IsLocked() (bool, error) { + lock, err := a.locker.CheckApplyLock() + + return lock.Locked, err +} + func (a *ApplyCommandRunner) isParallelEnabled(projectCmds []models.ProjectCommandContext) bool { return len(projectCmds) > 0 && projectCmds[0].ParallelApplyEnabled } diff --git a/server/events/apply_command_runner_test.go b/server/events/apply_command_runner_test.go new file mode 100644 index 0000000000..9a17e76589 --- /dev/null +++ b/server/events/apply_command_runner_test.go @@ -0,0 +1,69 @@ +package events_test + +import ( + "errors" + "testing" + + "github.com/google/go-github/v31/github" + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/locking" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/models/fixtures" +) + +func TestApplyCommandRunner_IsLocked(t *testing.T) { + RegisterMockTestingT(t) + + cases := []struct { + Description string + ApplyLocked bool + ApplyLockError error + ExpComment string + }{ + { + Description: "When global apply lock is present IsDisabled returns true", + ApplyLocked: true, + ApplyLockError: nil, + ExpComment: "**Error:** Running `atlantis apply` is disabled.", + }, + { + Description: "When no global apply lock is present and DisableApply flag is false IsDisabled returns false", + ApplyLocked: false, + ApplyLockError: nil, + ExpComment: "Ran Apply for 0 projects:\n\n\n\n", + }, + { + Description: "If ApplyLockChecker returns an error IsDisabled return value of DisableApply flag", + ApplyLockError: errors.New("error"), + ApplyLocked: false, + ExpComment: "Ran Apply for 0 projects:\n\n\n\n", + }, + } + + for _, c := range cases { + t.Run(c.Description, func(t *testing.T) { + vcsClient := setup(t) + + pull := &github.PullRequest{ + State: github.String("open"), + } + modelPull := models.PullRequest{BaseRepo: fixtures.GithubRepo, State: models.OpenPullState, Num: fixtures.Pull.Num} + When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil) + + ctx := &events.CommandContext{ + User: fixtures.User, + Log: noopLogger, + Pull: modelPull, + HeadRepo: fixtures.GithubRepo, + Trigger: events.Comment, + } + + When(applyLockChecker.CheckApplyLock()).ThenReturn(locking.ApplyCommandLock{Locked: c.ApplyLocked}, c.ApplyLockError) + applyCommandRunner.Run(ctx, &events.CommentCommand{Name: models.ApplyCommand}) + + vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, c.ExpComment, "apply") + }) + } +} diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 6ca31c1563..048ca25115 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -26,6 +26,7 @@ import ( "github.com/google/go-github/v31/github" . "github.com/petergtz/pegomock" "github.com/runatlantis/atlantis/server/events" + lockingmocks "github.com/runatlantis/atlantis/server/events/locking/mocks" "github.com/runatlantis/atlantis/server/events/mocks" eventmocks "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/mocks/matchers" @@ -59,6 +60,7 @@ var autoMerger *events.AutoMerger var policyCheckCommandRunner *events.PolicyCheckCommandRunner var approvePoliciesCommandRunner *events.ApprovePoliciesCommandRunner var planCommandRunner *events.PlanCommandRunner +var applyLockChecker *lockingmocks.MockApplyLockChecker var applyCommandRunner *events.ApplyCommandRunner var unlockCommandRunner *events.UnlockCommandRunner var preWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner @@ -85,6 +87,7 @@ func setup(t *testing.T) *vcsmocks.MockClient { drainer = &events.Drainer{} deleteLockCommand = eventmocks.NewMockDeleteLockCommand() + applyLockChecker = lockingmocks.NewMockApplyLockChecker() When(logger.GetLevel()).ThenReturn(logging.Info) When(logger.NewLogger("runatlantis/atlantis#1", true, logging.Info)). ThenReturn(pullLogger) @@ -131,7 +134,7 @@ func setup(t *testing.T) *vcsmocks.MockClient { applyCommandRunner = events.NewApplyCommandRunner( vcsClient, false, - false, + applyLockChecker, commitUpdater, projectCommandBuilder, projectCommandRunner, @@ -292,23 +295,6 @@ func TestRunCommentCommand_DisableApplyAllDisabled(t *testing.T) { vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "**Error:** Running `atlantis apply` without flags is disabled. You must specify which project to apply via the `-d `, `-w ` or `-p ` flags.", "apply") } -func TestRunCommentCommand_ApplyDisabled(t *testing.T) { - t.Log("if \"atlantis apply\" is run and this is disabled globally atlantis should" + - " comment saying that this is not allowed") - vcsClient := setup(t) - applyCommandRunner.DisableApply = true - defer func() { applyCommandRunner.DisableApply = false }() - pull := &github.PullRequest{ - State: github.String("open"), - } - modelPull := models.PullRequest{BaseRepo: fixtures.GithubRepo, State: models.OpenPullState, Num: fixtures.Pull.Num} - When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil) - When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil) - - ch.RunCommentCommand(fixtures.GithubRepo, nil, nil, fixtures.User, modelPull.Num, &events.CommentCommand{Name: models.ApplyCommand}) - vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "**Error:** Running `atlantis apply` is disabled.", "apply") -} - func TestRunCommentCommand_DisableDisableAutoplan(t *testing.T) { t.Log("if \"DisableAutoplan is true\" are disabled and we are silencing return and do not comment with error") setup(t) diff --git a/server/events/db/boltdb.go b/server/events/db/boltdb.go index 898d99d359..9a95c789c9 100644 --- a/server/events/db/boltdb.go +++ b/server/events/db/boltdb.go @@ -17,15 +17,17 @@ import ( // BoltDB is a database using BoltDB type BoltDB struct { - db *bolt.DB - locksBucketName []byte - pullsBucketName []byte + db *bolt.DB + locksBucketName []byte + pullsBucketName []byte + globalLocksBucketName []byte } const ( - locksBucketName = "runLocks" - pullsBucketName = "pulls" - pullKeySeparator = "::" + locksBucketName = "runLocks" + pullsBucketName = "pulls" + globalLocksBucketName = "globalLocks" + pullKeySeparator = "::" ) // New returns a valid locker. We need to be able to write to dataDir @@ -50,18 +52,31 @@ func New(dataDir string) (*BoltDB, error) { if _, err = tx.CreateBucketIfNotExists([]byte(pullsBucketName)); err != nil { return errors.Wrapf(err, "creating bucket %q", pullsBucketName) } + if _, err = tx.CreateBucketIfNotExists([]byte(globalLocksBucketName)); err != nil { + return errors.Wrapf(err, "creating bucket %q", globalLocksBucketName) + } 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 + return &BoltDB{ + db: db, + locksBucketName: []byte(locksBucketName), + pullsBucketName: []byte(pullsBucketName), + globalLocksBucketName: []byte(globalLocksBucketName), + }, 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 +func NewWithDB(db *bolt.DB, bucket string, globalBucket string) (*BoltDB, error) { + return &BoltDB{ + db: db, + locksBucketName: []byte(bucket), + pullsBucketName: []byte(pullsBucketName), + globalLocksBucketName: []byte(globalBucket), + }, nil } // TryLock attempts to create a new lock. If the lock is @@ -155,6 +170,87 @@ func (b *BoltDB) List() ([]models.ProjectLock, error) { return locks, nil } +// LockCommand attempts to create a new lock for a CommandName. +// If the lock doesn't exists, it will create a lock and return a pointer to it. +// If the lock already exists, it will return an "lock already exists" error +func (b *BoltDB) LockCommand(cmdName models.CommandName, lockTime time.Time) (*models.CommandLock, error) { + lock := models.CommandLock{ + CommandName: cmdName, + LockMetadata: models.LockMetadata{ + UnixTime: lockTime.Unix(), + }, + } + + newLockSerialized, _ := json.Marshal(lock) + transactionErr := b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.globalLocksBucketName) + + currLockSerialized := bucket.Get([]byte(b.commandLockKey(cmdName))) + if currLockSerialized != nil { + return errors.New("lock already exists") + } + + // This will only error on readonly buckets, it's okay to ignore. + bucket.Put([]byte(b.commandLockKey(cmdName)), newLockSerialized) // nolint: errcheck + return nil + }) + + if transactionErr != nil { + return nil, errors.Wrap(transactionErr, "db transaction failed") + } + + return &lock, nil +} + +// UnlockCommand removes CommandName lock if present. +// If there are no lock it returns an error. +func (b *BoltDB) UnlockCommand(cmdName models.CommandName) error { + transactionErr := b.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.globalLocksBucketName) + + if l := bucket.Get([]byte(b.commandLockKey(cmdName))); l == nil { + return errors.New("no lock exists") + } + + return bucket.Delete([]byte(b.commandLockKey(cmdName))) + }) + + if transactionErr != nil { + return errors.Wrap(transactionErr, "db transaction failed") + } + + return nil +} + +// CheckCommandLock checks if CommandName lock was set. +// If the lock exists return the pointer to the lock object, otherwise return nil +func (b *BoltDB) CheckCommandLock(cmdName models.CommandName) (*models.CommandLock, error) { + cmdLock := models.CommandLock{} + + found := false + + err := b.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(b.globalLocksBucketName) + + serializedLock := bucket.Get([]byte(b.commandLockKey(cmdName))) + + if serializedLock != nil { + if err := json.Unmarshal(serializedLock, &cmdLock); err != nil { + return errors.Wrap(err, "failed to deserialize UserConfig") + } + found = true + } + + return nil + }) + + if found { + return &cmdLock, err + } + + return nil, err +} + // 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 @@ -355,6 +451,10 @@ func (b *BoltDB) pullKey(pull models.PullRequest) ([]byte, error) { nil } +func (b *BoltDB) commandLockKey(cmdName models.CommandName) string { + return fmt.Sprintf("%s/lock", cmdName) +} + func (b *BoltDB) lockKey(p models.Project, workspace string) string { return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, workspace) } diff --git a/server/events/db/boltdb_test.go b/server/events/db/boltdb_test.go index 7129bb3ee6..d333eb5280 100644 --- a/server/events/db/boltdb_test.go +++ b/server/events/db/boltdb_test.go @@ -28,6 +28,7 @@ import ( ) var lockBucket = "bucket" +var configBucket = "configBucket" var project = models.NewProject("owner/repo", "parent/child") var workspace = "default" var pullNum = 1 @@ -43,6 +44,82 @@ var lock = models.ProjectLock{ Time: time.Now(), } +func TestLockCommandNotSet(t *testing.T) { + t.Log("retrieving apply lock when there are none should return empty LockCommand") + db, b := newTestDB() + defer cleanupDB(db) + exists, err := b.CheckCommandLock(models.ApplyCommand) + Ok(t, err) + Assert(t, exists == nil, "exp nil") +} + +func TestLockCommandEnabled(t *testing.T) { + t.Log("setting the apply lock") + db, b := newTestDB() + defer cleanupDB(db) + timeNow := time.Now() + _, err := b.LockCommand(models.ApplyCommand, timeNow) + Ok(t, err) + + config, err := b.CheckCommandLock(models.ApplyCommand) + Ok(t, err) + Equals(t, true, config.IsLocked()) +} + +func TestLockCommandFail(t *testing.T) { + t.Log("setting the apply lock") + db, b := newTestDB() + defer cleanupDB(db) + timeNow := time.Now() + _, err := b.LockCommand(models.ApplyCommand, timeNow) + Ok(t, err) + + _, err = b.LockCommand(models.ApplyCommand, timeNow) + ErrEquals(t, "db transaction failed: lock already exists", err) +} + +func TestUnlockCommandDisabled(t *testing.T) { + t.Log("unsetting the apply lock") + db, b := newTestDB() + defer cleanupDB(db) + timeNow := time.Now() + _, err := b.LockCommand(models.ApplyCommand, timeNow) + Ok(t, err) + + config, err := b.CheckCommandLock(models.ApplyCommand) + Ok(t, err) + Equals(t, true, config.IsLocked()) + + err = b.UnlockCommand(models.ApplyCommand) + Ok(t, err) + + config, err = b.CheckCommandLock(models.ApplyCommand) + Ok(t, err) + Assert(t, config == nil, "exp nil object") +} + +func TestUnlockCommandFail(t *testing.T) { + t.Log("setting the apply lock") + db, b := newTestDB() + defer cleanupDB(db) + err := b.UnlockCommand(models.ApplyCommand) + ErrEquals(t, "db transaction failed: no lock exists", err) +} + +func TestMixedLocksPresent(t *testing.T) { + db, b := newTestDB() + defer cleanupDB(db) + timeNow := time.Now() + _, err := b.LockCommand(models.ApplyCommand, timeNow) + Ok(t, err) + + _, _, err = b.TryLock(lock) + Ok(t, err) + ls, err := b.List() + Ok(t, err) + Equals(t, 1, len(ls)) +} + func TestListNoLocks(t *testing.T) { t.Log("listing locks when there are none should return an empty list") db, b := newTestDB() @@ -353,7 +430,7 @@ func TestGetLock(t *testing.T) { Equals(t, lock.User, l.User) } -// Test we can create a status and then get it. +// Test we can create a status and then getCommandLock it. func TestPullStatus_UpdateGet(t *testing.T) { b, cleanup := newTestDB2(t) defer cleanup() @@ -404,7 +481,7 @@ func TestPullStatus_UpdateGet(t *testing.T) { }, status.Projects) } -// Test we can create a status, delete it, and then we shouldn't be able to get +// Test we can create a status, delete it, and then we shouldn't be able to getCommandLock // it. func TestPullStatus_UpdateDeleteGet(t *testing.T) { b, cleanup := newTestDB2(t) @@ -450,7 +527,7 @@ func TestPullStatus_UpdateDeleteGet(t *testing.T) { } // Test we can create a status, update a specific project's status within that -// pull status, and when we get all the project statuses, that specific project +// pull status, and when we getCommandLock all the project statuses, that specific project // should be updated. func TestPullStatus_UpdateProject(t *testing.T) { b, cleanup := newTestDB2(t) @@ -661,7 +738,7 @@ func TestPullStatus_UpdateMerge(t *testing.T) { getStatus, err := b.GetPullStatus(pull) Ok(t, err) - // Test both the pull state returned from the update call *and* the get + // Test both the pull state returned from the update call *and* the getCommandLock // call. for _, s := range []models.PullStatus{updateStatus, *getStatus} { Equals(t, pull, s.Pull) @@ -710,11 +787,14 @@ func newTestDB() (*bolt.DB, *db.BoltDB) { if _, err := tx.CreateBucketIfNotExists([]byte(lockBucket)); err != nil { return errors.Wrap(err, "failed to create bucket") } + if _, err := tx.CreateBucketIfNotExists([]byte(configBucket)); err != nil { + return errors.Wrap(err, "failed to create bucket") + } return nil }); err != nil { panic(errors.Wrap(err, "could not create bucket")) } - b, _ := db.NewWithDB(boltDB, lockBucket) + b, _ := db.NewWithDB(boltDB, lockBucket, configBucket) return boltDB, b } diff --git a/server/events/locking/apply_locking.go b/server/events/locking/apply_locking.go new file mode 100644 index 0000000000..bb13104433 --- /dev/null +++ b/server/events/locking/apply_locking.go @@ -0,0 +1,113 @@ +package locking + +import ( + "errors" + "time" + + "github.com/runatlantis/atlantis/server/events/models" +) + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_apply_lock_checker.go ApplyLockChecker + +// ApplyLockChecker is an implementation of the global apply lock retrieval. +// It returns an object that contains information about apply locks status. +type ApplyLockChecker interface { + CheckApplyLock() (ApplyCommandLock, error) +} + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_apply_locker.go ApplyLocker + +// ApplyLocker interface that manages locks for apply command runner +type ApplyLocker interface { + // LockApply creates a lock for ApplyCommand if lock already exists it will + // return existing lock without any changes + LockApply() (ApplyCommandLock, error) + // UnlockApply deletes apply lock created by LockApply if present, otherwise + // it is a no-op + UnlockApply() error + ApplyLockChecker +} + +// ApplyCommandLock contains information about apply command lock status. +type ApplyCommandLock struct { + // Locked is true is when apply commands are locked + // Either by using DisableApply flag or creating a global ApplyCommandLock + // DisableApply lock take precedence when set + Locked bool + Time time.Time + Failure string +} + +type ApplyClient struct { + backend Backend + disableApplyFlag bool +} + +func NewApplyClient(backend Backend, disableApplyFlag bool) ApplyLocker { + return &ApplyClient{ + backend: backend, + disableApplyFlag: disableApplyFlag, + } +} + +// LockApply acquires global apply lock. +// DisableApplyFlag takes presedence to any existing locks, if it is set to true +// this function returns an error +func (c *ApplyClient) LockApply() (ApplyCommandLock, error) { + response := ApplyCommandLock{} + + if c.disableApplyFlag { + return response, errors.New("DisableApplyFlag is set; Apply commands are locked globally until flag is unset") + } + + applyCmdLock, err := c.backend.LockCommand(models.ApplyCommand, time.Now()) + if err != nil { + return response, err + } + + if applyCmdLock != nil { + response.Locked = true + response.Time = applyCmdLock.LockTime() + } + return response, nil +} + +// UnlockApply releases a global apply lock. +// DisableApplyFlag takes presedence to any existing locks, if it is set to true +// this function returns an error +func (c *ApplyClient) UnlockApply() error { + if c.disableApplyFlag { + return errors.New("apply commands are disabled until DisableApply flag is unset") + } + + err := c.backend.UnlockCommand(models.ApplyCommand) + if err != nil { + return err + } + + return nil +} + +// CheckApplyLock retrieves an apply command lock if present. +// If DisableApplyFlag is set it will always return a lock. +func (c *ApplyClient) CheckApplyLock() (ApplyCommandLock, error) { + response := ApplyCommandLock{} + + if c.disableApplyFlag { + return ApplyCommandLock{ + Locked: true, + }, nil + } + + applyCmdLock, err := c.backend.CheckCommandLock(models.ApplyCommand) + if err != nil { + return response, err + } + + if applyCmdLock != nil { + response.Locked = true + response.Time = applyCmdLock.LockTime() + } + + return response, nil +} diff --git a/server/events/locking/locking.go b/server/events/locking/locking.go index 7e9ee78b45..17f6b8aadf 100644 --- a/server/events/locking/locking.go +++ b/server/events/locking/locking.go @@ -32,6 +32,10 @@ type Backend interface { List() ([]models.ProjectLock, error) GetLock(project models.Project, workspace string) (*models.ProjectLock, error) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) + + LockCommand(cmdName models.CommandName, lockTime time.Time) (*models.CommandLock, error) + UnlockCommand(cmdName models.CommandName) error + CheckCommandLock(cmdName models.CommandName) (*models.CommandLock, error) } // TryLockResponse results from an attempted lock. diff --git a/server/events/locking/locking_test.go b/server/events/locking/locking_test.go index fe2ba6229a..37b7f4ec92 100644 --- a/server/events/locking/locking_test.go +++ b/server/events/locking/locking_test.go @@ -181,3 +181,107 @@ func TestGetLock_NoOpLocker(t *testing.T) { var expected *models.ProjectLock = nil Equals(t, expected, lock) } + +func TestApplyLocker(t *testing.T) { + RegisterMockTestingT(t) + applyLock := &models.CommandLock{ + CommandName: models.ApplyCommand, + LockMetadata: models.LockMetadata{ + UnixTime: time.Now().Unix(), + }, + } + + t.Run("LockApply", func(t *testing.T) { + t.Run("backend errors", func(t *testing.T) { + backend := mocks.NewMockBackend() + + When(backend.LockCommand(matchers.AnyModelsCommandName(), matchers.AnyTimeTime())).ThenReturn(nil, errExpected) + l := locking.NewApplyClient(backend, false) + lock, err := l.LockApply() + Equals(t, errExpected, err) + Assert(t, !lock.Locked, "exp false") + }) + + t.Run("can't lock if userConfig.DisableApply is set", func(t *testing.T) { + backend := mocks.NewMockBackend() + + l := locking.NewApplyClient(backend, true) + _, err := l.LockApply() + ErrEquals(t, "DisableApplyFlag is set; Apply commands are locked globally until flag is unset", err) + + backend.VerifyWasCalled(Never()).LockCommand(matchers.AnyModelsCommandName(), matchers.AnyTimeTime()) + }) + + t.Run("succeeds", func(t *testing.T) { + backend := mocks.NewMockBackend() + + When(backend.LockCommand(matchers.AnyModelsCommandName(), matchers.AnyTimeTime())).ThenReturn(applyLock, nil) + l := locking.NewApplyClient(backend, false) + lock, _ := l.LockApply() + Assert(t, lock.Locked, "exp lock present") + }) + }) + + t.Run("UnlockApply", func(t *testing.T) { + t.Run("backend fails", func(t *testing.T) { + backend := mocks.NewMockBackend() + + When(backend.UnlockCommand(matchers.AnyModelsCommandName())).ThenReturn(errExpected) + l := locking.NewApplyClient(backend, false) + err := l.UnlockApply() + Equals(t, errExpected, err) + }) + + t.Run("can't unlock if userConfig.DisableApply is set", func(t *testing.T) { + backend := mocks.NewMockBackend() + + l := locking.NewApplyClient(backend, true) + err := l.UnlockApply() + ErrEquals(t, "apply commands are disabled until DisableApply flag is unset", err) + + backend.VerifyWasCalled(Never()).UnlockCommand(matchers.AnyModelsCommandName()) + }) + + t.Run("succeeds", func(t *testing.T) { + backend := mocks.NewMockBackend() + + When(backend.UnlockCommand(matchers.AnyModelsCommandName())).ThenReturn(nil) + l := locking.NewApplyClient(backend, false) + err := l.UnlockApply() + Equals(t, nil, err) + }) + + }) + + t.Run("CheckApplyLock", func(t *testing.T) { + t.Run("fails", func(t *testing.T) { + backend := mocks.NewMockBackend() + + When(backend.CheckCommandLock(matchers.AnyModelsCommandName())).ThenReturn(nil, errExpected) + l := locking.NewApplyClient(backend, false) + lock, err := l.CheckApplyLock() + Equals(t, errExpected, err) + Equals(t, lock.Locked, false) + }) + + t.Run("when DisableApply flag is set always return a lock", func(t *testing.T) { + backend := mocks.NewMockBackend() + + l := locking.NewApplyClient(backend, true) + lock, err := l.CheckApplyLock() + Ok(t, err) + Equals(t, lock.Locked, true) + backend.VerifyWasCalled(Never()).CheckCommandLock(matchers.AnyModelsCommandName()) + }) + + t.Run("UnlockCommand succeeds", func(t *testing.T) { + backend := mocks.NewMockBackend() + + When(backend.CheckCommandLock(matchers.AnyModelsCommandName())).ThenReturn(applyLock, nil) + l := locking.NewApplyClient(backend, false) + lock, err := l.CheckApplyLock() + Equals(t, nil, err) + Assert(t, lock.Locked, "exp lock present") + }) + }) +} diff --git a/server/events/locking/mocks/matchers/locking_applycommandlockresponse.go b/server/events/locking/mocks/matchers/locking_applycommandlockresponse.go new file mode 100644 index 0000000000..735879321f --- /dev/null +++ b/server/events/locking/mocks/matchers/locking_applycommandlockresponse.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + locking "github.com/runatlantis/atlantis/server/events/locking" +) + +func AnyLockingApplyCommandLockResponse() locking.ApplyCommandLock { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(locking.ApplyCommandLock))(nil)).Elem())) + var nullValue locking.ApplyCommandLock + return nullValue +} + +func EqLockingApplyCommandLockResponse(value locking.ApplyCommandLock) locking.ApplyCommandLock { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue locking.ApplyCommandLock + return nullValue +} diff --git a/server/events/locking/mocks/matchers/models_commandlock.go b/server/events/locking/mocks/matchers/models_commandlock.go new file mode 100644 index 0000000000..87669404be --- /dev/null +++ b/server/events/locking/mocks/matchers/models_commandlock.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyModelsCommandLock() models.CommandLock { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.CommandLock))(nil)).Elem())) + var nullValue models.CommandLock + return nullValue +} + +func EqModelsCommandLock(value models.CommandLock) models.CommandLock { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue models.CommandLock + return nullValue +} diff --git a/server/events/locking/mocks/matchers/models_commandname.go b/server/events/locking/mocks/matchers/models_commandname.go new file mode 100644 index 0000000000..6eba95f8ee --- /dev/null +++ b/server/events/locking/mocks/matchers/models_commandname.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyModelsCommandName() models.CommandName { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.CommandName))(nil)).Elem())) + var nullValue models.CommandName + return nullValue +} + +func EqModelsCommandName(value models.CommandName) models.CommandName { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue models.CommandName + return nullValue +} diff --git a/server/events/locking/mocks/matchers/ptr_to_models_commandlock.go b/server/events/locking/mocks/matchers/ptr_to_models_commandlock.go new file mode 100644 index 0000000000..bac0e1a4d9 --- /dev/null +++ b/server/events/locking/mocks/matchers/ptr_to_models_commandlock.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyPtrToModelsCommandLock() *models.CommandLock { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*models.CommandLock))(nil)).Elem())) + var nullValue *models.CommandLock + return nullValue +} + +func EqPtrToModelsCommandLock(value *models.CommandLock) *models.CommandLock { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue *models.CommandLock + return nullValue +} diff --git a/server/events/locking/mocks/matchers/time_time.go b/server/events/locking/mocks/matchers/time_time.go new file mode 100644 index 0000000000..b08cb5e177 --- /dev/null +++ b/server/events/locking/mocks/matchers/time_time.go @@ -0,0 +1,20 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "reflect" + "github.com/petergtz/pegomock" + time "time" +) + +func AnyTimeTime() time.Time { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(time.Time))(nil)).Elem())) + var nullValue time.Time + return nullValue +} + +func EqTimeTime(value time.Time) time.Time { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue time.Time + return nullValue +} diff --git a/server/events/locking/mocks/mock_apply_lock_checker.go b/server/events/locking/mocks/mock_apply_lock_checker.go new file mode 100644 index 0000000000..15b5294d38 --- /dev/null +++ b/server/events/locking/mocks/mock_apply_lock_checker.go @@ -0,0 +1,99 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events/locking (interfaces: ApplyLockChecker) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + locking "github.com/runatlantis/atlantis/server/events/locking" + "reflect" + "time" +) + +type MockApplyLockChecker struct { + fail func(message string, callerSkip ...int) +} + +func NewMockApplyLockChecker(options ...pegomock.Option) *MockApplyLockChecker { + mock := &MockApplyLockChecker{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockApplyLockChecker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockApplyLockChecker) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockApplyLockChecker) CheckApplyLock() (locking.ApplyCommandLock, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockApplyLockChecker().") + } + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("CheckApplyLock", params, []reflect.Type{reflect.TypeOf((*locking.ApplyCommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 locking.ApplyCommandLock + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(locking.ApplyCommandLock) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockApplyLockChecker) VerifyWasCalledOnce() *VerifierMockApplyLockChecker { + return &VerifierMockApplyLockChecker{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockApplyLockChecker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockApplyLockChecker { + return &VerifierMockApplyLockChecker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockApplyLockChecker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockApplyLockChecker { + return &VerifierMockApplyLockChecker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockApplyLockChecker) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockApplyLockChecker { + return &VerifierMockApplyLockChecker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockApplyLockChecker struct { + mock *MockApplyLockChecker + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockApplyLockChecker) CheckApplyLock() *MockApplyLockChecker_CheckApplyLock_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CheckApplyLock", params, verifier.timeout) + return &MockApplyLockChecker_CheckApplyLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockApplyLockChecker_CheckApplyLock_OngoingVerification struct { + mock *MockApplyLockChecker + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockApplyLockChecker_CheckApplyLock_OngoingVerification) GetCapturedArguments() { +} + +func (c *MockApplyLockChecker_CheckApplyLock_OngoingVerification) GetAllCapturedArguments() { +} diff --git a/server/events/locking/mocks/mock_apply_locker.go b/server/events/locking/mocks/mock_apply_locker.go new file mode 100644 index 0000000000..3275a975d1 --- /dev/null +++ b/server/events/locking/mocks/mock_apply_locker.go @@ -0,0 +1,167 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events/locking (interfaces: ApplyLocker) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + locking "github.com/runatlantis/atlantis/server/events/locking" + "reflect" + "time" +) + +type MockApplyLocker struct { + fail func(message string, callerSkip ...int) +} + +func NewMockApplyLocker(options ...pegomock.Option) *MockApplyLocker { + mock := &MockApplyLocker{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockApplyLocker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockApplyLocker) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockApplyLocker) LockApply() (locking.ApplyCommandLock, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockApplyLocker().") + } + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("LockApply", params, []reflect.Type{reflect.TypeOf((*locking.ApplyCommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 locking.ApplyCommandLock + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(locking.ApplyCommandLock) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockApplyLocker) UnlockApply() error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockApplyLocker().") + } + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("UnlockApply", 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 *MockApplyLocker) CheckApplyLock() (locking.ApplyCommandLock, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockApplyLocker().") + } + params := []pegomock.Param{} + result := pegomock.GetGenericMockFrom(mock).Invoke("CheckApplyLock", params, []reflect.Type{reflect.TypeOf((*locking.ApplyCommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 locking.ApplyCommandLock + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(locking.ApplyCommandLock) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockApplyLocker) VerifyWasCalledOnce() *VerifierMockApplyLocker { + return &VerifierMockApplyLocker{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockApplyLocker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockApplyLocker { + return &VerifierMockApplyLocker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockApplyLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockApplyLocker { + return &VerifierMockApplyLocker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockApplyLocker) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockApplyLocker { + return &VerifierMockApplyLocker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockApplyLocker struct { + mock *MockApplyLocker + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockApplyLocker) LockApply() *MockApplyLocker_LockApply_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "LockApply", params, verifier.timeout) + return &MockApplyLocker_LockApply_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockApplyLocker_LockApply_OngoingVerification struct { + mock *MockApplyLocker + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockApplyLocker_LockApply_OngoingVerification) GetCapturedArguments() { +} + +func (c *MockApplyLocker_LockApply_OngoingVerification) GetAllCapturedArguments() { +} + +func (verifier *VerifierMockApplyLocker) UnlockApply() *MockApplyLocker_UnlockApply_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UnlockApply", params, verifier.timeout) + return &MockApplyLocker_UnlockApply_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockApplyLocker_UnlockApply_OngoingVerification struct { + mock *MockApplyLocker + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockApplyLocker_UnlockApply_OngoingVerification) GetCapturedArguments() { +} + +func (c *MockApplyLocker_UnlockApply_OngoingVerification) GetAllCapturedArguments() { +} + +func (verifier *VerifierMockApplyLocker) CheckApplyLock() *MockApplyLocker_CheckApplyLock_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CheckApplyLock", params, verifier.timeout) + return &MockApplyLocker_CheckApplyLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockApplyLocker_CheckApplyLock_OngoingVerification struct { + mock *MockApplyLocker + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockApplyLocker_CheckApplyLock_OngoingVerification) GetCapturedArguments() { +} + +func (c *MockApplyLocker_CheckApplyLock_OngoingVerification) GetAllCapturedArguments() { +} diff --git a/server/events/locking/mocks/mock_backend.go b/server/events/locking/mocks/mock_backend.go index 073dee2034..3bad8fc614 100644 --- a/server/events/locking/mocks/mock_backend.go +++ b/server/events/locking/mocks/mock_backend.go @@ -124,6 +124,59 @@ func (mock *MockBackend) UnlockByPull(repoFullName string, pullNum int) ([]model return ret0, ret1 } +func (mock *MockBackend) LockCommand(cmdName models.CommandName, lockTime time.Time) (*models.CommandLock, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockBackend().") + } + params := []pegomock.Param{cmdName, lockTime} + result := pegomock.GetGenericMockFrom(mock).Invoke("LockCommand", params, []reflect.Type{reflect.TypeOf((**models.CommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 *models.CommandLock + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(*models.CommandLock) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockBackend) UnlockCommand(cmdName models.CommandName) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockBackend().") + } + params := []pegomock.Param{cmdName} + result := pegomock.GetGenericMockFrom(mock).Invoke("UnlockCommand", 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 *MockBackend) CheckCommandLock(cmdName models.CommandName) (*models.CommandLock, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockBackend().") + } + params := []pegomock.Param{cmdName} + result := pegomock.GetGenericMockFrom(mock).Invoke("CheckCommandLock", params, []reflect.Type{reflect.TypeOf((**models.CommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 *models.CommandLock + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(*models.CommandLock) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + func (mock *MockBackend) VerifyWasCalledOnce() *VerifierMockBackend { return &VerifierMockBackend{ mock: mock, @@ -297,3 +350,88 @@ func (c *MockBackend_UnlockByPull_OngoingVerification) GetAllCapturedArguments() } return } + +func (verifier *VerifierMockBackend) LockCommand(cmdName models.CommandName, lockTime time.Time) *MockBackend_LockCommand_OngoingVerification { + params := []pegomock.Param{cmdName, lockTime} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "LockCommand", params, verifier.timeout) + return &MockBackend_LockCommand_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockBackend_LockCommand_OngoingVerification struct { + mock *MockBackend + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockBackend_LockCommand_OngoingVerification) GetCapturedArguments() (models.CommandName, time.Time) { + cmdName, lockTime := c.GetAllCapturedArguments() + return cmdName[len(cmdName)-1], lockTime[len(lockTime)-1] +} + +func (c *MockBackend_LockCommand_OngoingVerification) GetAllCapturedArguments() (_param0 []models.CommandName, _param1 []time.Time) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.CommandName, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.CommandName) + } + _param1 = make([]time.Time, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(time.Time) + } + } + return +} + +func (verifier *VerifierMockBackend) UnlockCommand(cmdName models.CommandName) *MockBackend_UnlockCommand_OngoingVerification { + params := []pegomock.Param{cmdName} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UnlockCommand", params, verifier.timeout) + return &MockBackend_UnlockCommand_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockBackend_UnlockCommand_OngoingVerification struct { + mock *MockBackend + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockBackend_UnlockCommand_OngoingVerification) GetCapturedArguments() models.CommandName { + cmdName := c.GetAllCapturedArguments() + return cmdName[len(cmdName)-1] +} + +func (c *MockBackend_UnlockCommand_OngoingVerification) GetAllCapturedArguments() (_param0 []models.CommandName) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.CommandName, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.CommandName) + } + } + return +} + +func (verifier *VerifierMockBackend) CheckCommandLock(cmdName models.CommandName) *MockBackend_CheckCommandLock_OngoingVerification { + params := []pegomock.Param{cmdName} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CheckCommandLock", params, verifier.timeout) + return &MockBackend_CheckCommandLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockBackend_CheckCommandLock_OngoingVerification struct { + mock *MockBackend + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockBackend_CheckCommandLock_OngoingVerification) GetCapturedArguments() models.CommandName { + cmdName := c.GetAllCapturedArguments() + return cmdName[len(cmdName)-1] +} + +func (c *MockBackend_CheckCommandLock_OngoingVerification) GetAllCapturedArguments() (_param0 []models.CommandName) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.CommandName, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.CommandName) + } + } + return +} diff --git a/server/events/mocks/mock_apply_command_locker.go b/server/events/mocks/mock_apply_command_locker.go new file mode 100644 index 0000000000..37fc004d75 --- /dev/null +++ b/server/events/mocks/mock_apply_command_locker.go @@ -0,0 +1,105 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events (interfaces: ApplyCommandLocker) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + events "github.com/runatlantis/atlantis/server/events" + "reflect" + "time" +) + +type MockApplyCommandLocker struct { + fail func(message string, callerSkip ...int) +} + +func NewMockApplyCommandLocker(options ...pegomock.Option) *MockApplyCommandLocker { + mock := &MockApplyCommandLocker{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockApplyCommandLocker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockApplyCommandLocker) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockApplyCommandLocker) IsDisabled(ctx *events.CommandContext) bool { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockApplyCommandLocker().") + } + params := []pegomock.Param{ctx} + result := pegomock.GetGenericMockFrom(mock).Invoke("IsDisabled", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) + var ret0 bool + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(bool) + } + } + return ret0 +} + +func (mock *MockApplyCommandLocker) VerifyWasCalledOnce() *VerifierMockApplyCommandLocker { + return &VerifierMockApplyCommandLocker{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockApplyCommandLocker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockApplyCommandLocker { + return &VerifierMockApplyCommandLocker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockApplyCommandLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockApplyCommandLocker { + return &VerifierMockApplyCommandLocker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockApplyCommandLocker) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockApplyCommandLocker { + return &VerifierMockApplyCommandLocker{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockApplyCommandLocker struct { + mock *MockApplyCommandLocker + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockApplyCommandLocker) IsDisabled(ctx *events.CommandContext) *MockApplyCommandLocker_IsDisabled_OngoingVerification { + params := []pegomock.Param{ctx} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "IsDisabled", params, verifier.timeout) + return &MockApplyCommandLocker_IsDisabled_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockApplyCommandLocker_IsDisabled_OngoingVerification struct { + mock *MockApplyCommandLocker + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockApplyCommandLocker_IsDisabled_OngoingVerification) GetCapturedArguments() *events.CommandContext { + ctx := c.GetAllCapturedArguments() + return ctx[len(ctx)-1] +} + +func (c *MockApplyCommandLocker_IsDisabled_OngoingVerification) GetAllCapturedArguments() (_param0 []*events.CommandContext) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*events.CommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(*events.CommandContext) + } + } + return +} diff --git a/server/events/models/models.go b/server/events/models/models.go index 39052ef3de..6ce3271256 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -206,6 +206,27 @@ type User struct { Username string } +// LockMetadata contains additional data provided to the lock +type LockMetadata struct { + UnixTime int64 +} + +// CommandLock represents a global lock for an atlantis command (plan, apply, policy_check). +// It is used to prevent commands from being executed +type CommandLock struct { + // Time is the time at which the lock was first created. + LockMetadata LockMetadata + CommandName CommandName +} + +func (l *CommandLock) LockTime() time.Time { + return time.Unix(l.LockMetadata.UnixTime, 0) +} + +func (l *CommandLock) IsLocked() bool { + return !l.LockTime().IsZero() +} + // ProjectLock represents a lock on a project. type ProjectLock struct { // Project is the project that is being locked. diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index 823cf5945a..181ec7ca70 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -37,6 +37,9 @@ import ( . "github.com/runatlantis/atlantis/testing" ) +var applyLocker locking.ApplyLocker +var userConfig server.UserConfig + type NoopTFDownloader struct{} var mockPreWorkflowHookRunner *runtimemocks.MockPreWorkflowHookRunner @@ -73,6 +76,10 @@ func TestGitHubWorkflow(t *testing.T) { ModifiedFiles []string // Comments are what our mock user writes to the pull request. Comments []string + // DisableApply flag used by userConfig object when initializing atlantis server. + DisableApply bool + // ApplyLock creates an apply lock that temporarily disables apply command + ApplyLock bool // ExpAutomerge is true if we expect Atlantis to automerge. ExpAutomerge bool // ExpAutoplan is true if we expect Atlantis to autoplan. @@ -336,12 +343,48 @@ func TestGitHubWorkflow(t *testing.T) { {"exp-output-merge.txt"}, }, }, + { + Description: "global apply lock disables apply commands", + RepoDir: "simple-yaml", + ModifiedFiles: []string{"main.tf"}, + DisableApply: false, + ApplyLock: true, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply", + }, + ExpReplies: [][]string{ + {"exp-output-autoplan.txt"}, + {"exp-output-apply-locked.txt"}, + {"exp-output-merge.txt"}, + }, + }, + { + Description: "disable apply flag always takes presedence", + RepoDir: "simple-yaml", + ModifiedFiles: []string{"main.tf"}, + DisableApply: true, + ApplyLock: false, + ExpAutoplan: true, + Comments: []string{ + "atlantis apply", + }, + ExpReplies: [][]string{ + {"exp-output-autoplan.txt"}, + {"exp-output-apply-locked.txt"}, + {"exp-output-merge.txt"}, + }, + }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { RegisterMockTestingT(t) - ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, false) + // reset userConfig + userConfig = server.UserConfig{} + userConfig.DisableApply = c.DisableApply + + 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() @@ -357,6 +400,11 @@ func TestGitHubWorkflow(t *testing.T) { ctrl.Post(w, pullOpenedReq) responseContains(t, w, 200, "Processing...") + // Create global apply lock if required + if c.ApplyLock { + _, _ = applyLocker.LockApply() + } + // Now send any other comments. for _, comment := range c.Comments { commentReq := GitHubCommentEvent(t, comment) @@ -489,7 +537,11 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { t.Run(c.Description, func(t *testing.T) { RegisterMockTestingT(t) - ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, true) + // reset userConfig + userConfig = server.UserConfig{} + userConfig.EnablePolicyChecksFlag = true + + 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() @@ -559,14 +611,14 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { } } -func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.EventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) { +func setupE2E(t *testing.T, repoDir string) (server.EventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) { allowForkPRs := false dataDir, binDir, cacheDir, cleanup := mkSubDirs(t) defer cleanup() //env vars - if policyChecksEnabled { + if userConfig.EnablePolicyChecksFlag { // need this to be set or we'll fail the policy check step os.Setenv(policy.DefaultConftestVersionEnvKey, "0.21.0") } @@ -594,6 +646,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev boltdb, err := db.New(dataDir) Ok(t, err) lockingClient := locking.NewClient(boltdb) + applyLocker = locking.NewApplyClient(boltdb, userConfig.DisableApply) projectLocker := &events.DefaultProjectLocker{ Locker: lockingClient, VCSClient: e2eVCSClient, @@ -630,7 +683,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev PreWorkflowHookRunner: mockPreWorkflowHookRunner, } projectCommandBuilder := events.NewProjectCommandBuilder( - policyChecksEnabled, + userConfig.EnablePolicyChecksFlag, parser, &events.DefaultProjectFinder{}, e2eVCSClient, @@ -729,7 +782,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev applyCommandRunner := events.NewApplyCommandRunner( e2eVCSClient, false, - false, + applyLocker, e2eStatusUpdater, projectCommandBuilder, projectCommandRunner, diff --git a/server/locks_controller.go b/server/locks_controller.go index 60151b16d9..66313dce04 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -20,6 +20,7 @@ type LocksController struct { AtlantisVersion string AtlantisURL *url.URL Locker locking.Locker + ApplyLocker locking.ApplyLocker Logger *logging.SimpleLogger VCSClient vcs.Client LockDetailTemplate TemplateWriter @@ -29,6 +30,30 @@ type LocksController struct { DeleteLockCommand events.DeleteLockCommand } +// LockApply handles creating a global apply lock. +// If Lock already exists it will be a no-op +func (l *LocksController) LockApply(w http.ResponseWriter, r *http.Request) { + lock, err := l.ApplyLocker.LockApply() + if err != nil { + l.respond(w, logging.Error, http.StatusInternalServerError, "creating apply lock failed with: %s", err) + return + } + + l.respond(w, logging.Info, http.StatusOK, "Apply Lock is acquired on %s", lock.Time.Format("2006-01-02 15:04:05")) +} + +// UnlockApply handles releasing a global apply lock. +// If Lock doesn't exists it will be a no-op +func (l *LocksController) UnlockApply(w http.ResponseWriter, r *http.Request) { + err := l.ApplyLocker.UnlockApply() + if err != nil { + l.respond(w, logging.Error, http.StatusInternalServerError, "deleting apply lock failed with: %s", err) + return + } + + l.respond(w, logging.Info, http.StatusOK, "Deleted apply lock") +} + // GetLock is the GET /locks/{id} route. It renders the lock detail view. func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) { id, ok := mux.Vars(r)["id"] diff --git a/server/locks_controller_test.go b/server/locks_controller_test.go index 42487afbf4..bdd0d5ae6b 100644 --- a/server/locks_controller_test.go +++ b/server/locks_controller_test.go @@ -3,13 +3,16 @@ package server_test import ( "bytes" "errors" + "fmt" "net/http" "net/http/httptest" "net/url" "reflect" "testing" + "time" "github.com/runatlantis/atlantis/server/events/db" + "github.com/runatlantis/atlantis/server/events/locking" "github.com/gorilla/mux" . "github.com/petergtz/pegomock" @@ -30,6 +33,84 @@ func AnyRepo() models.Repo { return models.Repo{} } +func TestCreateApplyLock(t *testing.T) { + t.Run("Creates apply lock", func(t *testing.T) { + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + w := httptest.NewRecorder() + + layout := "2006-01-02T15:04:05.000Z" + strLockTime := "2020-09-01T00:45:26.371Z" + expLockTime := "2020-09-01 00:45:26" + lockTime, _ := time.Parse(layout, strLockTime) + + l := mocks.NewMockApplyLocker() + When(l.LockApply()).ThenReturn(locking.ApplyCommandLock{ + Locked: true, + Time: lockTime, + }, nil) + + lc := server.LocksController{ + Logger: logging.NewNoopLogger(), + ApplyLocker: l, + } + lc.LockApply(w, req) + + responseContains(t, w, http.StatusOK, fmt.Sprintf("Apply Lock is acquired on %s", expLockTime)) + }) + + t.Run("Apply lock creation fails", func(t *testing.T) { + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + w := httptest.NewRecorder() + + l := mocks.NewMockApplyLocker() + When(l.LockApply()).ThenReturn(locking.ApplyCommandLock{ + Locked: false, + }, errors.New("failed to acquire lock")) + + lc := server.LocksController{ + Logger: logging.NewNoopLogger(), + ApplyLocker: l, + } + lc.LockApply(w, req) + + responseContains(t, w, http.StatusInternalServerError, fmt.Sprintf("creating apply lock failed with: failed to acquire lock")) + }) +} + +func TestUnlockApply(t *testing.T) { + t.Run("Apply lock deleted successfully", func(t *testing.T) { + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + w := httptest.NewRecorder() + + l := mocks.NewMockApplyLocker() + When(l.UnlockApply()).ThenReturn(nil) + + lc := server.LocksController{ + Logger: logging.NewNoopLogger(), + ApplyLocker: l, + } + lc.UnlockApply(w, req) + + responseContains(t, w, http.StatusOK, fmt.Sprintf("Deleted apply lock")) + }) + + t.Run("Apply lock deletion failed", func(t *testing.T) { + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + w := httptest.NewRecorder() + + l := mocks.NewMockApplyLocker() + When(l.UnlockApply()).ThenReturn(errors.New("failed to delete lock")) + + lc := server.LocksController{ + Logger: logging.NewNoopLogger(), + ApplyLocker: l, + } + lc.UnlockApply(w, req) + + responseContains(t, w, http.StatusInternalServerError, fmt.Sprintf("deleting apply lock failed with: failed to delete lock")) + }) +} + func TestGetLockRoute_NoLockID(t *testing.T) { t.Log("If there is no lock ID in the request then we should get a 400") req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) diff --git a/server/server.go b/server/server.go index a0eb0e694c..0d2ae59a7f 100644 --- a/server/server.go +++ b/server/server.go @@ -84,6 +84,7 @@ type Server struct { CommandRunner *events.DefaultCommandRunner Logger *logging.SimpleLogger Locker locking.Locker + ApplyLocker locking.ApplyLocker EventsController *EventsController GithubAppController *GithubAppController LocksController *LocksController @@ -294,10 +295,12 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { return nil, err } var lockingClient locking.Locker + var applyLockingClient locking.ApplyLocker if userConfig.DisableRepoLocking { lockingClient = locking.NewNoOpLocker() } else { lockingClient = locking.NewClient(boltdb) + applyLockingClient = locking.NewApplyClient(boltdb, userConfig.DisableApply) } workingDirLocker := events.NewDefaultWorkingDirLocker() @@ -501,7 +504,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { applyCommandRunner := events.NewApplyCommandRunner( vcsClient, userConfig.DisableApplyAll, - userConfig.DisableApply, + applyLockingClient, commitStatusUpdater, projectCommandBuilder, projectCommandRunner, @@ -556,6 +559,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { AtlantisVersion: config.AtlantisVersion, AtlantisURL: parsedURL, Locker: lockingClient, + ApplyLocker: applyLockingClient, Logger: logger, VCSClient: vcsClient, LockDetailTemplate: lockTemplate, @@ -601,6 +605,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { CommandRunner: commandRunner, Logger: logger, Locker: lockingClient, + ApplyLocker: applyLockingClient, EventsController: eventsController, GithubAppController: githubAppController, LocksController: locksController, @@ -624,6 +629,8 @@ func (s *Server) Start() error { s.Router.HandleFunc("/events", s.EventsController.Post).Methods("POST") s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET") s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET") + s.Router.HandleFunc("/apply/lock", s.LocksController.LockApply).Methods("POST").Queries() + s.Router.HandleFunc("/apply/unlock", s.LocksController.UnlockApply).Methods("DELETE").Queries() s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}") s.Router.HandleFunc("/lock", s.LocksController.GetLock).Methods("GET"). Queries(LockViewRouteIDQueryParam, fmt.Sprintf("{%s}", LockViewRouteIDQueryParam)).Name(LockViewRouteName) @@ -710,11 +717,25 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { }) } + applyCmdLock, err := s.ApplyLocker.CheckApplyLock() + s.Logger.Info("Apply Lock: %v", applyCmdLock) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "Could not retrieve global apply lock: %s", err) + return + } + + applyLockData := ApplyLockData{ + Time: applyCmdLock.Time, + Locked: applyCmdLock.Locked, + TimeFormatted: applyCmdLock.Time.Format("02-01-2006 15:04:05"), + } //Sort by date - newest to oldest. sort.SliceStable(lockResults, func(i, j int) bool { return lockResults[i].Time.After(lockResults[j].Time) }) err = s.IndexTemplate.Execute(w, IndexData{ Locks: lockResults, + ApplyLock: applyLockData, AtlantisVersion: s.AtlantisVersion, CleanedBasePath: s.AtlantisURL.Path, }) diff --git a/server/server_test.go b/server/server_test.go index 7be7947af2..27a34ef231 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -76,6 +76,7 @@ func TestIndex_Success(t *testing.T) { t.Log("Index should render the index template successfully.") RegisterMockTestingT(t) l := mocks.NewMockLocker() + al := mocks.NewMockApplyLocker() // These are the locks that we expect to be rendered. now := time.Now() locks := map[string]models.ProjectLock{ @@ -100,6 +101,7 @@ func TestIndex_Success(t *testing.T) { Ok(t, err) s := server.Server{ Locker: l, + ApplyLocker: al, IndexTemplate: it, Router: r, AtlantisVersion: atlantisVersion, @@ -109,6 +111,11 @@ func TestIndex_Success(t *testing.T) { w := httptest.NewRecorder() s.Index(w, req) it.VerifyWasCalledOnce().Execute(w, server.IndexData{ + ApplyLock: server.ApplyLockData{ + Locked: false, + Time: time.Time{}, + TimeFormatted: "01-01-0001 00:00:00", + }, Locks: []server.LockIndexData{ { LockPath: "/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", diff --git a/server/testfixtures/test-repos/simple-yaml/exp-output-apply-locked.txt b/server/testfixtures/test-repos/simple-yaml/exp-output-apply-locked.txt new file mode 100644 index 0000000000..9c9f52f43a --- /dev/null +++ b/server/testfixtures/test-repos/simple-yaml/exp-output-apply-locked.txt @@ -0,0 +1 @@ +**Error:** Running `atlantis apply` is disabled. diff --git a/server/web_templates.go b/server/web_templates.go index e7ba1a852d..5aefa76dcc 100644 --- a/server/web_templates.go +++ b/server/web_templates.go @@ -40,9 +40,17 @@ type LockIndexData struct { TimeFormatted string } +// ApplyLockData holds the fields to display in the index view +type ApplyLockData struct { + Locked bool + Time time.Time + TimeFormatted string +} + // IndexData holds the data for rendering the index page type IndexData struct { Locks []LockIndexData + ApplyLock ApplyLockData AtlantisVersion string // CleanedBasePath is the path Atlantis is accessible at externally. If // not using a path-based proxy, this will be an empty string. Never ends @@ -80,11 +88,23 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`

atlantis

Plan discarded and unlocked!

- - + {{ else }} +
+
Apply commands are enabled
+ Disable Apply Commands +
+ {{ end }} + +
+

Locks

@@ -103,10 +123,112 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`

No locks found.

{{ end }}
+ +
v{{ .AtlantisVersion }}
+ `)) @@ -185,7 +307,7 @@ v{{ .AtlantisVersion }}